GAP·MAP

WebSockets & realtime

[ middle depth ]

How the WebSocket handshake works

A WebSocket starts life as an ordinary HTTP/1.1 GET carrying Upgrade: websocket and Connection: Upgrade. The server answers 101 Switching Protocols, and from that point the same TCP connection stops speaking HTTP. It carries the WebSocket frame protocol instead: full-duplex, both sides sending whenever they want, until someone closes it. Contrast HTTP, where each request gets one response and the client always speaks first.

GET /chat HTTP/1.1              ->   HTTP/1.1 101 Switching Protocols
Upgrade: websocket                   Upgrade: websocket
Sec-WebSocket-Key: <nonce>           Sec-WebSocket-Accept: <hash of key>
Sec-WebSocket-Version: 13

Wrong "Sec-WebSocket-Key secures the connection." The browser sets that key on its own as a random nonce, and the server hashes it into Sec-WebSocket-Accept only to prove it understands the protocol. Right it is a handshake sanity check, not auth and not encryption. Encryption is what wss:// (WebSocket over TLS) gives you.

When to reach for SSE instead of WebSocket

Ask two questions: does the client need to push, and do you need binary?

If both answers are no (notifications, a live feed, token streaming, a price ticker), use SSE. Server-Sent Events run over plain HTTP, carry UTF-8 text only, and the browser reconnects for you, resending the Last-Event-ID header so the server can replay what you missed. That reconnect-and-resume comes for free.

Pick a WebSocket when the client also sends, or you need binary, or you want the lowest latency (chat, multiplayer, collaborative editing). Long polling, where the server holds a request open until it has data, is the universal fallback for hostile proxies, at the cost of per-message HTTP overhead.

WebSockets do not reconnect for you

The browser WebSocket API has no reconnect and no resume. When the socket drops you get a close event and nothing else happens. You write the reconnect loop.

Do not retry on a fixed interval. When a server restarts, every client that was on it reconnects at the same instant, and the synchronized spike knocks it over again (a thundering herd). Use exponential backoff with jitter: grow the delay each attempt and add randomness so clients spread out. Cap the delay, cap the attempt count, and reset the backoff once a connection has been stable for a while. To resume rather than only reconnect, tag each message with a sequence id and re-request the gap after you are back.

Detecting a dead connection with heartbeats

A peer can vanish (a crash, a NAT dropping the flow) and leave your side half-open: the socket still reads OPEN, no error fires, but nothing more will arrive. These zombie connections pile up and hold memory.

You might reach for protocol ping/pong frames, but a browser cannot send or even see them from JavaScript (it auto-answers server pings behind your back). So browser clients run an application-level heartbeat: send a small {"type":"ping"} on a timer and begin reconnecting if the matching reply does not return in time. TCP keepalive will not cover you here, since its first probe defaults to two hours.

[ senior depth ]

Why the client must mask every frame

After the handshake, data moves in frames. The first byte carries the FIN bit and a 4-bit opcode (0x1 text, 0x2 binary, 0x0 continuation, 0x8 close, 0x9 ping, 0xA pong); ping, pong, and close are control frames, never fragmented, with payloads capped at 125 bytes. Every client-to-server frame MUST carry a random 32-bit masking key, and a server that receives an unmasked client frame must close the connection. Server-to-client frames are never masked.

Wrong "Masking is pointless over wss, TLS already encrypts it." It is mandatory even over TLS, and it is not about confidentiality. Right it defeats cache-poisoning against intermediary proxies, where attacker-controlled JavaScript could otherwise craft bytes a proxy misreads as a second HTTP request and caches.

Scaling stateful WebSocket fan-out

The defining fact: a WebSocket is long-lived and pinned to the one node that terminated its upgrade, for the connection's whole life. Stateless HTTP lets any request hit any box; this does not.

Routing comes first. A reconnect has to reach a node that holds the client's state, so you use sticky sessions or, more durably, keep session state in an external store (Redis, a DB) so any node can serve any client. Sticky sessions alone are fragile: when a node dies, all of its pinned clients drop and reconnect at once, and load skews.

Then fan-out. A node can only write to the sockets it holds, so to broadcast to a room whose members span nodes you publish to a shared pub/sub backplane (Redis, Kafka, NATS) that every node subscribes to and re-emits locally. Redis Pub/Sub is fire-and-forget with no replay, so add a log and sequence ids when you need resume. Watch backpressure too: a slow consumer's bufferedAmount grows unbounded and can OOM the node. Each idle connection costs a file descriptor plus a few KB to roughly 10 KB, so active fan-out throughput, not raw connection count, is the real ceiling on a node.

Tuning heartbeats to the shortest idle timeout

A heartbeat has to fire more often than the tightest idle timeout on the path (load balancer, reverse proxy, NAT), or an intermediary silently kills the connection between beats. A common rule is about 75% of the shortest timeout, so roughly 45s for a 60s proxy. TCP keepalive does not help: its first probe defaults to 7200s on Linux, and proxies track application traffic, not TCP probes.

On a server or a non-browser client, use protocol ping/pong: send a ping, and if no pong echoes back within the timeout, close the connection as dead. The Python websockets library defaults to a 20s interval and a 20s timeout. When the network dies with no close frame, the code you observe is 1006 (abnormal closure), which an app can never set itself.

WebTransport over HTTP/3

WebTransport runs over HTTP/3, which runs on QUIC over UDP (encrypted and congestion-controlled). One connection offers two delivery models: reliable ordered streams (createBidirectionalStream, built on the Streams API for real backpressure) and unreliable unordered datagrams (transport.datagrams) for state that supersedes itself, like a game position where a resent stale update is worthless.

Its edge over WebSocket is no head-of-line blocking. A WebSocket rides one TCP stream, so a single lost packet stalls every logical message until it is retransmitted. QUIC streams are independent, so loss on one does not block the others, and a QUIC connection survives a network change through its connection id. WebTransport is reaching cross-browser Baseline availability in 2026 but is younger than WebSocket, so feature-detect and keep a WebSocket fallback.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

more Browser & Network

was this useful?