WebSockets
A WebSocket starts as an HTTP request that asks to switch protocols, gets 101 Switching Protocols, and from then on the same TCP connection carries framed messages in both directions for as long as both sides want — which makes every proxy, load balancer and idle timer between them part of the design.
The problem
The handshake is an HTTP request
A WebSocket (RFC 6455) begins as an ordinary HTTP/1.1 GET to make it pass through everything that already understands HTTP — the same port 80/443, the same TLS, the same cookies. The request carries Upgrade: websocket, Connection: Upgrade, a random Sec-WebSocket-Key, and optionally the subprotocols and extensions it wants. A server that agrees answers `101 Switching Protocols` with Sec-WebSocket-Accept = base64(SHA-1(key + a fixed GUID)) — not a security measure, but proof that the responder actually understood the WebSocket handshake rather than being an HTTP cache echoing headers. From the end of that response, the TCP connection no longer carries HTTP: it carries WebSocket frames until one side sends a close frame.
The upgrade is HTTP/1.1-specific because Upgrade is a hop-by-hop header forbidden in HTTP/2; RFC 8441 defines an extended CONNECT method that tunnels a WebSocket inside one HTTP/2 stream, supported by some browsers and proxies but not universally, so most WebSocket deployments still run on HTTP/1.1 connections. Browser WebSocket objects cannot set custom headers, so authentication is a cookie, a token in the URL query, or a first message after connect — Authorization headers do not exist here.
GET /chat HTTP/1.1 Host: example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13 Sec-WebSocket-Protocol: chat.v2 Origin: https://app.example.com HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= Sec-WebSocket-Protocol: chat.v2 <from here on: WebSocket frames, not HTTP>
Frames: messages, not bytes
TCP gives a byte stream; the first thing every protocol on top of it does is invent message boundaries, and WebSocket does it once for everyone. Each frame has a 2–14-byte header: a FIN bit (last fragment of a message), an opcode (text 0x1, binary 0x2, close 0x8, ping 0x9, pong 0xA, continuation 0x0), a mask bit, and a payload length encoded in 7, 16 or 64 bits. Large messages may be fragmented across frames; the receiver reassembles by FIN. The application sees onmessage with a complete text or binary message — no length prefixes, no delimiters, no partial reads.
Masking is the odd rule: every frame from client to server must be XORed with a random 4-byte key carried in the frame header; server-to-client frames must not be. It protects not the data but the internet: without it, a malicious page could craft a WebSocket payload that looks like an HTTP request and poison a badly written proxy’s cache for other users. Masking makes the bytes on the wire unpredictable to the page author. Servers reject unmasked client frames; libraries handle it invisibly.
Ping/pong control frames give liveness: either side sends a ping, the other must answer with a pong carrying the same payload. Browsers do not expose pings to JavaScript, so applications add their own heartbeat message. What WebSocket does *not* give is request/response: messages have no ids and no correlation, so an application that needs "send a query, get its answer" must build ids, timeouts and matching itself — or use a protocol layered on top (STOMP, GraphQL over WebSocket, Socket.IO, JSON-RPC).
- Frame = FIN + opcode + mask + length + payload; messages are text (UTF-8) or binary; control frames (ping/pong/close) are ≤ 125 bytes.
- Per-message overhead is 2–6 bytes server→client, 6–14 client→server — versus hundreds of bytes of HTTP headers per request.
- Close is a handshake: a close frame with a status code (1000 normal, 1001 going away, 1006 abnormal — never sent, observed on drop), then the TCP close.
- No request/response semantics; correlation is the application’s job.
What it does to your infrastructure
A WebSocket is a long-lived connection that carries no HTTP after the first request, and every piece of HTTP infrastructure between the browser and the server has to be told. Reverse proxies must forward the upgrade: nginx needs proxy_http_version 1.1, proxy_set_header Upgrade $http_upgrade and Connection "upgrade", or the client gets a 200 with an HTML page instead of a 101 (Forward and Reverse Proxies). Load balancers must either work at L4 or support upgrade at L7; either way the connection is pinned to one backend for its lifetime, so balancing happens at connect time only — a backend added during scale-out receives no existing connections, and a backend removed during a deploy drops all of its connections at once, producing a reconnect storm against the survivors (Load Balancers: L4 vs L7).
Idle timeouts apply throughout: an ALB closes connections idle for 60 s, nginx after proxy_read_timeout (60 s), NATs after minutes. A quiet WebSocket is closed by whichever timer fires first, and the browser sees onclose with code 1006. The fix is a heartbeat below the shortest timeout in the path, and a client reconnect loop with exponential backoff and jitter — every serious WebSocket client is really a reconnect state machine.
Fan-out and state are the scaling problem. The server that holds a user’s connection is the only one that can send to it. When a message must reach 10,000 users spread across 20 servers, the servers need a shared bus — Redis pub/sub, NATS, Kafka — so that a publish on one node reaches the node holding each subscriber. Presence ("who is online") and per-user routing therefore live in shared state, not in process memory, and a server holding 50,000 connections is a C10K: Ten Thousand Connections, Then a Million problem: one event loop, non-blocking I/O, and a bufferedAmount check before sending so a slow client cannot exhaust memory (What Happens When the Receiver Is Slow).
1function connect(url: string, onMessage: (m: unknown) => void) {2 let attempt = 03 let heartbeat: ReturnType<typeof setInterval> | undefined4 const open = () => {5 const ws = new WebSocket(url) // no custom headers: auth via cookie or query token6 ws.onopen = () => {7 attempt = 08 heartbeat = setInterval(() => ws.readyState === ws.OPEN && ws.send('{"t":"ping"}'), 25_000) // below the 60 s LB idle timeout9 }10 ws.onmessage = (e) => onMessage(JSON.parse(e.data))11 ws.onclose = (e) => { // 1006 = dropped without a close frame (timeout, deploy, network change)12 clearInterval(heartbeat)13 const delay = Math.min(30_000, 500 * 2 ** attempt++) * (0.5 + Math.random()) // backoff + jitter14 setTimeout(open, delay)15 }16 }17 open()18}Key points
- A WebSocket is an HTTP/1.1 request with
Upgrade: websocketanswered by101 Switching Protocols; after that the TCP connection carries WebSocket frames, not HTTP. - Frames give message boundaries (text/binary), fragmentation, ping/pong and close; per-message overhead is a few bytes instead of hundreds of header bytes.
- Client-to-server frames are masked to protect intermediaries from cache poisoning, not to protect the data.
- There is no request/response: ids, correlation and timeouts are the application’s job or a subprotocol’s.
- Proxies and load balancers must support the upgrade; connections are pinned to a backend at connect time, so scale-out and deploys behave differently from HTTP.
- Idle timeouts at every hop close quiet connections; heartbeats and a reconnect loop with backoff are mandatory.
- Fan-out across servers needs a shared bus (Redis pub/sub, NATS); presence and routing live in shared state.
Why does this exist?
Mechanisms are answers to constraints. Open each question before reading the answer.
▸Why start with an HTTP request instead of a new protocol on a new port?
So it traverses the firewalls, proxies, TLS termination and cookies that already exist for HTTP on 80/443. A new port would be blocked in most corporate networks; an upgrade rides on what is already allowed.
▸Why mask client frames?
A page can make a browser open a WebSocket and send arbitrary bytes; unmasked, those bytes could be shaped like an HTTP request and confuse a transparent proxy into caching an attacker’s response for other users. Random masking makes the on-wire bytes unpredictable to the page.
▸Why does a WebSocket need a heartbeat when TCP is reliable?
Reliability is not liveness. A NAT or load balancer silently drops an idle mapping, and neither side learns until the next write fails minutes later. A periodic small message keeps the mapping alive and detects a dead peer within one interval.
▸Why does horizontal scaling need pub/sub?
Because the connection is state on one server: only the process holding the socket can write to it. Any message that must reach a user on another node has to be routed there through something all nodes share.
WebSocket upgrade
- GET /chat HTTP/1.1 Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13▶
- ◀HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
- same TCP connection, HTTP is over: framed messages in both directions
- TEXT "hi" FIN=1 MASK=1▶
- ◀TEXT "hello, client" MASK=0
- ◀BINARY 1.2 KB MASK=0
- PING "hb" MASK=1▶
- ◀PONG "hb"
- CLOSE 1000 (normal) MASK=1▶
- ◀CLOSE 1000
GET /chat HTTP/1.1 Host: engineer-atlas.dev Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13 HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
How it fails
What the failure looks like from inside real software.
- Client receives
200 OKand HTML instead of101: the reverse proxy did not forwardUpgrade/Connectionheaders or downgraded to HTTP/1.0. - Connections drop every 60 s with close code 1006: load balancer idle timeout with no heartbeat below it.
- Deploy of the WebSocket tier drops 200,000 connections at once; the reconnect storm overloads the remaining nodes and the auth service — needs jittered backoff and gradual draining (
1001 Going Awaywith a delay). - New backend added under load receives no traffic because existing connections stay pinned; the old backends remain saturated.
- Messages published on node 1 never reach users connected to node 2: fan-out assumed a single process; no shared bus.
- Server memory grows until OOM: broadcasting to slow clients without checking
bufferedAmount/backpressure buffers gigabytes for a few stalled sockets.