WebSocket Real-Time Apps: Production Patterns
Build resilient WebSocket clients that survive disconnects, network changes, and backpressure.
Reconnect with Jittered Backoff
- class ReliableSocket {
- constructor(url) { this.url = url; this.attempt = 0; this.connect(); }
- connect() {
- this.ws = new WebSocket(this.url);
- this.ws.onopen = () => { this.attempt = 0; };
- this.ws.onclose = () => {
- const delay = Math.min(30000, 1000 * 2 ** this.attempt++);
- setTimeout(() => this.connect(), delay * (0.5 + Math.random() * 0.5));
- };
- }
- }
Details that matter: reset the attempt counter only after a stable connection (some teams require N seconds open, not just onopen, to avoid fast crash loops resetting backoff). The jitter prevents thundering herds — when a server restarts, ten thousand clients reconnecting at identical intervals is a self-inflicted DDoS. Cap the delay so recovery after a long outage is not glacial. And listen for the browser's online event plus visibilitychange to short-circuit the backoff when the network or tab returns — waiting 30 seconds after the user reopens the laptop feels broken.
Heartbeats
Idle connections silently die behind NATs and proxies, typically after 30–120 seconds; neither side gets a close frame. The browser API does not expose protocol-level ping frames, so heartbeats are application-level:
- setInterval(() => { if (ws.readyState === WebSocket.OPEN) ws.send('ping'); }, 25000);
The 25-second cadence stays under common 30-second proxy timeouts. Crucially, the heartbeat is a liveness detector, not just keepalive: if no pong arrives within ~10 seconds, treat the connection as dead, call close(), and let the reconnect logic run. Without this, a half-open connection can sit in readyState OPEN for minutes while every send disappears into a void. Servers should independently ping (they *do* get protocol pings) and reap dead clients to bound resource usage.
Resuming State After Reconnect
Reconnecting is easy; not losing messages is the actual problem. Patterns, in increasing robustness:
- Server-assigned sequence numbers. Client tracks the last seen ID and reconnects with a "since 1042" request; the server replays from a bounded buffer, or instructs a full resync if the gap outlived the buffer - Snapshot plus deltas. On connect, send full state, then incremental updates — the reconnect path and the initial-load path become the same code - CRDT or sync engine (Yjs, Automerge) — replays and conflicts handled transparently, at the cost of a data-model commitment
Whatever the transport promises, deliveries are effectively at-least-once across reconnects. Make message handlers idempotent — keyed by message ID — and duplicates become harmless.
Backpressure on Send
ws.send() never blocks; it queues into an internal buffer exposed as bufferedAmount. Pushing a large export to a slow mobile client just grows that buffer in your process memory:
- if (ws.bufferedAmount > 1_000_000) await waitForDrain(ws);
There is no drain event in the browser — poll bufferedAmount on an interval or before each chunk. On the server side, prefer libraries that surface backpressure (ws exposes the socket's write return; uWebSockets.js has explicit drain callbacks) and drop or coalesce updates for clients that cannot keep up: for live dashboards, sending the *latest* state beats faithfully queueing every stale intermediate.
Version Your Wire Format
The Sec-WebSocket-Protocol subprotocol field exists for this: the client offers chat-v2, chat-v1, the server picks one, and both sides know the dialect before the first message. Bumping versions lets servers support both formats during a rolling deploy — the alternative is a message-type switch inside a single ad-hoc JSON schema, which every team regrets by v3.
Auth Without Leaking Tokens
Browsers cannot set headers on the WebSocket handshake. Putting a JWT in the query string works but lands tokens in access logs and proxies. Better options: send a short-lived, single-use ticket obtained via authenticated fetch as the first message (server closes unauthenticated sockets after a timeout), or rely on cookies when same-origin. Re-authenticate on every reconnect — the token that opened the original socket may have expired hours ago.
Scaling Beyond One Server
WebSocket state is connection state, which makes horizontal scaling different from stateless HTTP. Two rules cover most architectures: use sticky sessions or a connection-aware load balancer so a client's socket and its session state share a process, and decouple message *production* from message *delivery* with a pub/sub layer (Redis, NATS, or a managed equivalent) so any app server can publish to a client connected anywhere. Budget file descriptors and memory per connection — 100k idle sockets are cheap, 100k sockets each buffering a slow send are not — and load-test disconnect storms, not just steady state.
Consider SSE Before WebSocket
If data flows only server-to-client — notifications, feeds, progress — Server-Sent Events auto-reconnect natively (with Last-Event-ID resume built in), traverse proxies as plain HTTP, and multiplex cleanly over HTTP/2 or HTTP/3. WebTransport is emerging for latency-critical bidirectional cases, but WebSocket earns its complexity only when the client genuinely streams data *up*. A surprising fraction of "real-time" features are SSE plus a normal POST.