WebSocket Protocol Fundamentals: Handshake, Framing, and Scaling
Concept
WebSocket (RFC 6455, standardized in 2011) provides a single, long-lived, full-duplex TCP connection between client and server, replacing the request/response cycle every other HTTP-based communication pattern is built around. The connection starts as an ordinary HTTP/1.1 request carrying an Upgrade: websocket header; if the server agrees, it responds 101 Switching Protocols, and the same TCP socket that carried that HTTP request is reused for a completely different wire format for the rest of its life — HTTP effectively hands off the pipe, and neither side goes back to it. This handshake-then-handoff design is deliberate: it lets a WebSocket connection traverse the exact same port 80/443 infrastructure (browsers, corporate proxies, most load balancers) that ordinary HTTP already does, because to anything only watching the handshake, it looks like a normal HTTP request.
After the handoff, data moves as a sequence of frames rather than HTTP messages: a frame header carries a FIN bit (is this the last fragment of the message), an opcode identifying the frame type (text, binary, or one of three control types), and a payload length, followed by the payload itself. Control frames matter operationally: Ping (opcode 0x9) and Pong (0xA) exist purely to prove the connection is still alive — an endpoint that receives a Ping must reply with a Pong, and libraries use this exchange as a heartbeat to detect a half-dead connection (one where the TCP session still looks up but nothing is actually flowing) well before an OS-level socket timeout would fire. A Close frame (0x8) performs an explicit, two-way handshake to end the connection cleanly, distinguishing "the other side is gone" from "the other side said goodbye." All control frames are capped at 125 bytes and must never be fragmented, keeping them cheap to process even under load.
Tradeoffs
| Approach | Benefit | Cost |
|---|---|---|
| Short polling | Simplest to implement; stateless, cache-friendly requests | Wasted requests when nothing changed; latency bounded by the poll interval |
| Long polling | Lower latency than short polling without a persistent transport | Still one HTTP request/response cycle per message; server holds a connection open per waiting client, straining connection pools at scale |
| Server-Sent Events (SSE) | Simple, works over plain HTTP, auto-reconnects, server push without a new protocol | One-directional (server → client) only — client must use a separate channel to send |
| WebSocket | True full-duplex — either side can push at any time on one connection; minimal per-frame overhead versus a full HTTP header set | Stateful connection per client that must be tracked somewhere (in-process or an external backplane); some proxies and older load balancers don't handle the Upgrade handshake or long-lived connections correctly by default |
When to use / when not to
- Use where both sides need to push with low latency and the interaction is genuinely bidirectional or high-frequency — chat, collaborative editing cursors, live multiplayer state, trading tickers.
- SSE is usually the better default when data only flows server → client (live scores, notification feeds, a progress bar): it's simpler, and ordinary HTTP infrastructure (caching proxies, HTTP/2 multiplexing) already understands it without special handling.
- Don't reach for a WebSocket just because "real-time" is a stated requirement — if updates are infrequent (every few seconds or slower) and effectively one-directional, long/short polling or SSE deliver the same perceived responsiveness for far less operational complexity.
- Avoid it for anything that's fundamentally a single request/response exchange with no ongoing session — a WebSocket's connection-lifecycle cost isn't worth paying for one-shot calls.
Common pitfall
Treating a WebSocket connection as stateless the way an HTTP request is. Because the connection is held open and pinned to one server process, horizontally scaling a WebSocket service means either sticky sessions at the load balancer (routing a client to the same backend instance for the life of its connection — fragile under instance restarts and uneven load) or a shared backplane (Redis Pub/Sub, a message broker) that lets any instance broadcast a message to a client connected to a different instance. Skipping this design step produces a service that works fine in a single-instance dev environment and silently drops messages the moment it's scaled to two.
Engineering Lens
The design question a WebSocket-based feature actually needs answered isn't "can we open a persistent connection" — it's "what happens to connection state when the backend process that owns it restarts, or the client's network blips," since both are certainties at scale, not edge cases. A reconnect strategy (exponential backoff, resuming from a last-known sequence number) and a horizontal-scaling story (sticky routing vs. a pub/sub backplane) are part of the feature's actual scope, not infrastructure to bolt on later — a chat feature that "works" without either of those is a demo, not a production service.