Realtime and WebSockets
Purpose
Build realtime features that behave correctly when the connection drops — which it will, constantly, on mobile networks. The hard part is not the socket; it is the state after the reconnect.
When to Use
- Building live updates, chat, presence, collaborative editing, or streaming dashboards.
- Choosing between WebSockets, SSE, and polling.
- Scaling a socket server beyond one instance.
- Diagnosing memory growth or dropped messages under load.
Capabilities
- Protocol selection and trade-offs.
- Connection lifecycle: handshake, auth, heartbeat, graceful close.
- Reconnection with resume tokens and gap backfill.
- Horizontal scaling with a pub/sub fan-out layer.
- Backpressure and slow-consumer handling.
Inputs
- Direction of data flow (server to client, or bidirectional).
- Message volume, size, and acceptable latency.
- Whether missed messages must be recovered or can be dropped.
Outputs
- A protocol choice with a stated reason.
- A reconnect path that restores correct state, not just a live socket.
- A scaling design that works with more than one server instance.
Workflow
- Choose the simplest protocol that works — Server-to-client only? Use SSE: it is plain HTTP, reconnects automatically, and passes through proxies. Bidirectional and low-latency? WebSockets. Infrequent updates? Polling is not embarrassing.
- Authenticate at the handshake — Validate the token on connect; re-validate periodically for long-lived sockets. A socket opened an hour ago may belong to a revoked session.
- Heartbeat both ways — Ping/pong with a timeout. Without it, half-open connections accumulate and leak memory for hours.
- Design the resume — Every message carries a monotonic sequence number. On reconnect, the client sends its last-seen ID; the server replays the gap or tells it to resynchronize fully.
- Fan out via pub/sub — With more than one instance, a message published on instance A must reach a client connected to instance B. Redis pub/sub, NATS, or the broker you already have.
- Apply backpressure — Bound the per-connection send buffer. When it fills, drop the slow consumer rather than the server.
Best Practices
- The reconnect path is the feature. Test it by killing the connection mid-stream, not by refreshing the page.
- Never trust the client's claimed last-seen sequence without bounding the replay. A malicious or buggy client can request a million-message backfill.
- Reconnect with exponential backoff and jitter. A server restart otherwise brings every client back simultaneously and takes it down again.
- Sticky sessions are a workaround, not a design. Make any instance able to serve any client.
- An unbounded outbound queue per connection is a memory leak with a slow client attached to it.
- Send binary or compact JSON; per-message overhead dominates at high message rates.
Examples
Resumable stream with sequence numbers:
// Client
const ws = new WebSocket(`${URL}?resume_from=${lastSeq ?? ""}`);
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.seq !== lastSeq + 1 && lastSeq !== null) {
// Gap detected: the server could not replay. Resynchronize from scratch.
return resyncFromSnapshot();
}
lastSeq = msg.seq;
apply(msg);
};
ws.onclose = () => scheduleReconnect(backoff.next()); // exponential + jitter
// Server: replay bounded, or instruct a full resync
const from = Number(url.searchParams.get("resume_from"));
const gap = currentSeq - from;
if (Number.isFinite(from) && gap > 0 && gap <= MAX_REPLAY) {
for (const msg of await log.range(from + 1, currentSeq)) send(socket, msg);
} else {
send(socket, { type: "resync_required", seq: currentSeq });
}
Notes
- SSE is limited to six concurrent connections per domain on HTTP/1.1 browsers. Over HTTP/2 this limit effectively disappears — check what your load balancer actually terminates.
- Load balancers commonly kill idle connections after 60 seconds. Your heartbeat interval must be shorter than the shortest idle timeout in the path.
- For collaborative editing, message replay is not enough — you need CRDTs or operational transformation. Sequence numbers give you delivery, not convergence.
1---2name: realtime-websockets3description: Use when building realtime features with WebSockets or Server-Sent Events. Covers protocol choice, connection lifecycle, reconnection and backfill, scaling across instances, and backpressure.4---56# Realtime and WebSockets78## Purpose910Build realtime features that behave correctly when the connection drops — which it will, constantly, on mobile networks. The hard part is not the socket; it is the state after the reconnect.1112## When to Use1314- Building live updates, chat, presence, collaborative editing, or streaming dashboards.15- Choosing between WebSockets, SSE, and polling.16- Scaling a socket server beyond one instance.17- Diagnosing memory growth or dropped messages under load.1819## Capabilities2021- Protocol selection and trade-offs.22- Connection lifecycle: handshake, auth, heartbeat, graceful close.23- Reconnection with resume tokens and gap backfill.24- Horizontal scaling with a pub/sub fan-out layer.25- Backpressure and slow-consumer handling.2627## Inputs2829- Direction of data flow (server to client, or bidirectional).30- Message volume, size, and acceptable latency.31- Whether missed messages must be recovered or can be dropped.3233## Outputs3435- A protocol choice with a stated reason.36- A reconnect path that restores correct state, not just a live socket.37- A scaling design that works with more than one server instance.3839## Workflow40411. **Choose the simplest protocol that works** — Server-to-client only? Use SSE: it is plain HTTP, reconnects automatically, and passes through proxies. Bidirectional and low-latency? WebSockets. Infrequent updates? Polling is not embarrassing.422. **Authenticate at the handshake** — Validate the token on connect; re-validate periodically for long-lived sockets. A socket opened an hour ago may belong to a revoked session.433. **Heartbeat both ways** — Ping/pong with a timeout. Without it, half-open connections accumulate and leak memory for hours.444. **Design the resume** — Every message carries a monotonic sequence number. On reconnect, the client sends its last-seen ID; the server replays the gap or tells it to resynchronize fully.455. **Fan out via pub/sub** — With more than one instance, a message published on instance A must reach a client connected to instance B. Redis pub/sub, NATS, or the broker you already have.466. **Apply backpressure** — Bound the per-connection send buffer. When it fills, drop the slow consumer rather than the server.4748## Best Practices4950- The reconnect path is the feature. Test it by killing the connection mid-stream, not by refreshing the page.51- Never trust the client's claimed last-seen sequence without bounding the replay. A malicious or buggy client can request a million-message backfill.52- Reconnect with exponential backoff and jitter. A server restart otherwise brings every client back simultaneously and takes it down again.53- Sticky sessions are a workaround, not a design. Make any instance able to serve any client.54- An unbounded outbound queue per connection is a memory leak with a slow client attached to it.55- Send binary or compact JSON; per-message overhead dominates at high message rates.5657## Examples5859**Resumable stream with sequence numbers:**6061```typescript62// Client63const ws = new WebSocket(`${URL}?resume_from=${lastSeq ?? ""}`);6465ws.onmessage = (e) => {66 const msg = JSON.parse(e.data);67 if (msg.seq !== lastSeq + 1 && lastSeq !== null) {68 // Gap detected: the server could not replay. Resynchronize from scratch.69 return resyncFromSnapshot();70 }71 lastSeq = msg.seq;72 apply(msg);73};7475ws.onclose = () => scheduleReconnect(backoff.next()); // exponential + jitter76```7778```typescript79// Server: replay bounded, or instruct a full resync80const from = Number(url.searchParams.get("resume_from"));81const gap = currentSeq - from;8283if (Number.isFinite(from) && gap > 0 && gap <= MAX_REPLAY) {84 for (const msg of await log.range(from + 1, currentSeq)) send(socket, msg);85} else {86 send(socket, { type: "resync_required", seq: currentSeq });87}88```8990## Notes9192- SSE is limited to six concurrent connections per domain on HTTP/1.1 browsers. Over HTTP/2 this limit effectively disappears — check what your load balancer actually terminates.93- Load balancers commonly kill idle connections after 60 seconds. Your heartbeat interval must be shorter than the shortest idle timeout in the path.94- For collaborative editing, message replay is not enough — you need CRDTs or operational transformation. Sequence numbers give you delivery, not convergence.