WebSockets & Scaling
WebSockets provide a persistent, bidirectional connection over a single TCP connection. They are the right tool when both sides must send low-latency messages: chat, collaboration, multiplayer, trading.
They are also the most operationally expensive real-time transport, because every connection is state pinned to an instance.
The handshake
GET /ws HTTP/1.1
Host: api.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: <base64 nonce>
Sec-WebSocket-Version: 13
The server responds 101 Switching Protocols, and the connection becomes framed WebSocket messages (text/binary). After the upgrade, normal HTTP semantics no longer apply: no per-message caching, status codes, or headers.
Framing and control
- Text frames for JSON; binary for protobuf/msgpack when volume matters.
- Ping/pong control frames keep middleboxes from closing idle connections.
- Close frames carry a code and reason.
- Message size limits and fragmentation are the server's responsibility.
Connection lifecycle
const ws = new WebSocket("wss://api.example.com/ws?token=" + shortLivedToken);
ws.onopen = () => {
ws.send(JSON.stringify({ type: "subscribe", channels: ["orders"] }));
};
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
handle(message);
};
ws.onclose = (event) => {
if (event.code !== 1000) reconnectWithBackoff();
};
Protocol design decisions:
| Concern | Recommendation |
|---|---|
| Message envelope | { id, type, data, ts } — stable type discriminator |
| Ordering | per-connection ordering exists; cross-connection ordering does not |
| Acknowledgements | explicit ack for critical messages; retries with idempotency |
| Heartbeat | app-level ping every 20–30 s + pong timeout |
| Resume | client sends lastMessageId; server replays missed messages or signals resync |
| Versioning | include v in the envelope; support overlap |
Authentication
- Authenticate during the handshake (cookie,
Authorization, or a short-lived token in the query string). - Revalidate on token expiry: send a
reauthmessage and close if not refreshed. - Do not trust the connection for the lifetime of a long session without re-checking permissions.
- For cookies, protect against CSRF on the handshake (SameSite/Origin checks), because browsers attach cookies automatically.
- Authorise per channel/room, not just per connection.
Backpressure: the hard part
A slow client must not make the server buffer unboundedly:
- track
ws.bufferedAmount; if it exceeds a threshold, drop low-value messages or close the connection; - prefer conflated state (send the latest value) over an unbounded event stream;
- apply per-connection quotas and rate limits;
- never broadcast large payloads synchronously to thousands of sockets in one loop.
function safeSend(ws: WebSocket, payload: string) {
if (ws.readyState !== ws.OPEN) return;
if (ws.bufferedAmount > 1_000_000) {
ws.close(1013, "client too slow");
return;
}
ws.send(payload);
}
Scaling
Each connection is stateful. The architecture:
client ──ws──> edge/LB ──> gateway instance ──subscribe──> pub/sub
│
└── presence/session store (Redis)
Key concerns:
- Connection limits per instance (file descriptors, memory, event loop cost). Plan capacity in connections, not just RPS.
- Routing: the instance holding the socket must receive the message. Use a shared pub/sub (Redis, NATS, Kafka) keyed by user/channel.
- Presence: which user is on which node? Store with TTL in Redis; clean up on disconnect.
- Broadcast: fan-out via pub/sub, not cross-node RPC per message.
- Sticky sessions: needed only if the LB cannot route by connection; WebSocket upgrade naturally sticks, but reconnects may land elsewhere — resume must not depend on the node.
- Deploys: drain connections (send
reconnect), wait, then close. Otherwise every deploy triggers a reconnect storm.
Message delivery guarantees
WebSockets give ordered delivery while connected, nothing more:
- messages are lost if the connection drops before delivery;
- reconnects need resume + event log or a full resync;
- critical actions need application-level acks and idempotency;
- do not use WebSockets as a durable queue.
For reliability, persist events (outbox/log) and let the client resume from a cursor.
Security
- Validate
Originon the handshake. - Rate limit connections per IP/user; cap message rate and size.
- Authorise channel subscriptions.
- Treat all input as untrusted; validate message schemas.
- Watch for connection exhaustion (Slowloris-style).
- Terminate TLS at the edge; use
wss://only.
WebSockets vs alternatives
| Need | Better choice |
|---|---|
| Server → client only | SSE (simpler, auto-reconnect, HTTP) |
| Request/response with occasional updates | REST + polling/SSE |
| Media streams | WebRTC |
| Durable event delivery | message queue + webhooks/consumers |
| Ultra-low latency bidirectional | WebSockets (or WebTransport) |
Failure modes
| Failure | Symptom | Fix |
|---|---|---|
| No heartbeat | idle disconnects via proxies | ping/pong + app-level keepalive |
| No backpressure | memory growth, OOM | bufferedAmount limits, conflation |
| Reconnect storm | CPU spike after deploy/outage | jitter, draining, capacity headroom |
| Auth never revalidated | revoked users keep access | reauth flow, periodic checks |
| Per-node broadcast | messages missing across nodes | pub/sub fan-out |
| Unbounded message size | abuse/DoS | size caps, schema validation |
| State on node | reconnect loses session | externalise session/resume state |
Checklist
- The use case truly needs bidirectional low latency.
- Heartbeats and close codes are implemented.
- Auth happens at handshake and is revalidated.
- Channels are authorised per subscription.
- Backpressure limits and message size caps exist.
- Resume/replay or full-resync semantics are defined.
- Fan-out uses a shared bus; presence is externalised.
- Deploys drain connections and reconnect with jitter.
- Capacity is planned in connections and memory, not RPS.
Next: Webhooks & Events — reliable delivery to consumers you do not control.