System Design · Synchronous Communication
WebSockets for Real-Time-ish Communication
How a single HTTP handshake turns into a persistent, bidirectional pipe — and what it actually takes to keep thousands of those pipes alive in production.
HTTP was built around a simple contract: the client asks, the server answers, and the connection is done. That model falls apart the moment you need the server to speak first — a chat message arriving from someone else, a price tick, a notification that something finished processing. Polling can fake it, but it's wasteful and laggy. WebSockets solve the actual problem: they turn a single HTTP request into a long-lived, full-duplex connection where either side can push data at any moment.
The handshake: HTTP that upgrades itself
A WebSocket connection starts life as an ordinary HTTP GET request, carrying an Upgrade: websocket header and a Sec-WebSocket-Key the server must echo back (cryptographically transformed) to prove it understood the request. If the server agrees, it responds with 101 Switching Protocols instead of a normal status code — and at that instant, the underlying TCP socket stops being an HTTP connection at all. No more request/response framing, no more headers on every message. What's left is a raw, persistent pipe that both sides can write to whenever they want, framed just enough to distinguish one message from the next.
This is the detail that trips people up: WebSockets aren't a layer on top of HTTP the way REST is. HTTP is only used to negotiate the upgrade. Once that handshake completes, you're in a different world — one where "request" and "response" stop being meaningful terms, because the server can send three messages before the client sends its next one, and that's completely normal.
Where WebSockets sit on the spectrum
It helps to place WebSockets between the two things people usually confuse them with. At one end is plain request/response HTTP — every exchange is client-initiated, and the server can't say anything unless asked. At the other end is WebRTC — genuinely peer-to-peer, negotiated once through a signaling server and then optimized for the kind of high-volume, loss-tolerant, low-latency media that audio and video calls need, with the server largely getting out of the way after setup.
WebSockets sit in between. They're bidirectional and persistent like WebRTC, but every message still travels through the server — there's no direct client-to-client path. That makes them "real-time-ish": latency is low enough for chat, live dashboards, collaborative editing, and game state that doesn't need sub-frame precision, but they were never designed to carry a continuous 30fps video stream efficiently. Reach for WebRTC when two clients need to exchange media directly; reach for WebSockets when you need low-latency updates flowing through a server you already trust and control.
Scaling a connection that remembers who you are
The uncomfortable part of WebSockets is that they're stateful. A regular HTTP request can land on any server behind a load balancer because nothing about it depends on which server handled the last one. A WebSocket connection, by contrast, is pinned to whichever server process accepted the handshake — that process is holding the open socket in memory, and the client stays attached to it for the life of the connection.
That pinning has to be respected by everything in front of it. Either the load balancer uses sticky sessions (routing a client back to the same backend by cookie or IP hash), or it's a connection-aware layer-4 balancer that simply doesn't try to redistribute an already-established connection. Get this wrong and a client's "send" call succeeds while landing on a server that has no idea who it is.
Pinning solves routing, but it creates a second problem: if a message needs to reach a user, and that user's socket is held by server B, how does server A — which has no shared memory with B — deliver it? This is where a pub/sub layer, usually Redis pub/sub or something similar, earns its place. Every server instance subscribes to the channels its connected clients care about; when any instance needs to push a message, it publishes once and every other instance that's holding a relevant socket forwards it locally. The WebSocket layer handles the last hop to the browser; pub/sub handles the fan-out between processes that would otherwise be isolated islands.
Connections die quietly
TCP doesn't reliably tell you when a connection has gone bad. A laptop moving between Wi-Fi networks, a corporate proxy silently timing out an idle socket, a mobile network switching towers — all of these can kill a WebSocket without either side receiving a clean close frame. If you only rely on the "connection closed" event, you'll find users stuck for minutes believing they're still live.
Heartbeats and reconnecting gracefully
Because dead connections don't always announce themselves, most production WebSocket systems run their own ping/pong heartbeat on top of the protocol's built-in ping frames: the server sends a small ping at a fixed interval, and if the client doesn't pong back within a timeout, the server tears the connection down and frees its resources rather than holding a socket that's actually dead. Clients run the same logic in reverse to detect a server that's gone silent.
The other half of the job is reconnection. A well-behaved client treats disconnection as routine, not exceptional: it retries with exponential backoff and jitter (so ten thousand clients don't all reconnect in the same instant after a server restart), and ideally resumes state rather than starting cold — sending a "last message ID I saw" on reconnect so the server can replay whatever was missed, instead of the client silently losing messages that arrived during the gap.
When you only need one direction: Server-Sent Events
A large share of "we need WebSockets" use cases only ever push data one way — server to client. Live scores, notification badges, streaming an LLM's response token by token. For that shape of problem, Server-Sent Events (SSE) is usually the better tool. SSE is plain HTTP/1.1: the client opens a normal request, the server keeps the response open and streams newline-delimited text/event-stream data down it indefinitely. There's no upgrade handshake, no separate protocol, and no special infrastructure — it passes through ordinary HTTP proxies and load balancers without the sticky-session concerns WebSockets bring.
Browsers also handle SSE's reconnection for you automatically via EventSource, including replaying from aLast-Event-ID the browser tracks on your behalf — the exact reconnect-and-resume logic you'd otherwise have to build by hand for a WebSocket. The trade-off is exactly what you'd expect: SSE can't send anything from client to server on the same stream, so the moment you need true two-way, low-latency traffic, you're back to WebSockets.
Key takeaways
- A WebSocket connection begins as an HTTP request that upgrades via a 101 response into a persistent, full-duplex TCP pipe — after that, HTTP semantics no longer apply.
- WebSockets sit between plain request/response HTTP and true peer-to-peer WebRTC: low-latency and bidirectional, but still relayed through a server and not built for high-volume media.
- Stateful connections need sticky sessions or connection-aware load balancing, plus a pub/sub layer like Redis to fan messages across server instances that don't share memory.
- Ping/pong heartbeats detect silently dead connections; clients should reconnect with backoff and resume from a last-seen message ID rather than starting cold.
- Server-Sent Events are the simpler choice for server-to-client-only push — plain HTTP, automatic browser reconnection, no upgrade handshake required.
