WebSockets and the Real-Time Web

Open a live sports score, a chat app, a collaborative document where you can see other people's cursors moving, or a trading dashboard where prices tick in real time. In every case, the server has news and needs to get it to your screen the instant it happens. But the web was not built for that. HTTP, the protocol the whole web runs on, is request/response: the client asks a question, the server answers, and then the conversation is over. The server has no way to speak up on its own. It cannot push.

So how did the web learn to feel alive? The answer is a thirty-year arc of increasingly clever workarounds and, finally, a protocol built for the job: the WebSocket, a persistent, full-duplex, bidirectional channel that begins its life as an ordinary HTTP request and then transforms into a raw two-way pipe. This page walks that whole evolution — polling, long-polling, Server-Sent Events, and WebSockets — so you understand not just how real-time works but why each rung of the ladder exists.

The fundamental mismatch

HTTP's request/response shape is a wonderful fit for fetching pages: you ask for a URL, you get a document, done. Each exchange is independent and the connection can be closed and forgotten. But that same shape is exactly wrong for real-time updates, because the interesting event — "a new message arrived", "the price moved", "your teammate typed" — originates at the server, at a moment the client cannot predict. In pure HTTP, the client would have to somehow guess when to ask. The whole history of real-time web is a series of answers to one question:

how does the server get data to the client without the client having asked for that specific thing right now?

There are only two fundamental strategies, and everything else is a refinement of them:

Climbing the ladder

Each technique fixes the flaw of the one before it. It is genuinely a ladder, and knowing the rungs is how you choose the right tool.

How a WebSocket is born: the HTTP Upgrade handshake

The clever part of WebSockets is how they start. A WebSocket does not bypass the web — it begins as a perfectly ordinary HTTP GET that politely asks to be upgraded. The client sends headers like these:

GET /chat HTTP/1.1 Host: example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13

If the server agrees, it replies with a special status code — 101 Switching Protocols — and from that instant the same TCP connection stops speaking HTTP and becomes a raw WebSocket channel:

HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

This is why WebSockets work through the existing web infrastructure — they ride ports 80/443 and start as HTTP, so firewalls and proxies let them through — yet escape HTTP's request/response straitjacket once upgraded. The handshake is the bridge from the old world to the new.

After the handshake: frames and opcodes

Once upgraded, the two sides no longer send HTTP requests — they exchange frames. A WebSocket frame is a lightweight envelope: a few bytes of header (an opcode saying what kind of frame it is, a length, and — from client to server — a masking key) wrapped around the payload. That is the source of the efficiency: where each long-poll message dragged a fat block of HTTP headers along, a WebSocket message adds only a handful of framing bytes.

The opcode tells the receiver how to treat the frame. The important ones:

OpcodeMeaning
0x1 texta UTF-8 text message (e.g. JSON)
0x2 binarya binary message (e.g. an image chunk)
0x8 closebegin a clean shutdown of the connection
0x9 pingheartbeat: "are you still there?"
0xA pongheartbeat reply: "yes, still here"

The ping/pong pair matters more than it looks: a persistent connection that goes quiet is indistinguishable, to the network, from a dead one. Intermediaries (proxies, NAT boxes, load balancers) happily drop idle TCP connections after a minute or two. So both ends periodically ping and expect a pong; a missing pong means the peer is gone and the connection should be torn down and re-established. Keeping the pipe alive is your job, not magic.

Count the cost yourself

Why does everyone reach for persistent connections? Overhead. The demo below simulates a 60-second window in which the server has 3 real pieces of news to deliver, and counts the network round-trips and the bytes of overhead each strategy spends to deliver them. The numbers make the case better than prose.

// A 60-second window; the server actually has news at these 3 moments (seconds). const WINDOW = 60; const newsAt = [12, 37, 38]; const HTTP_OVERHEAD = 500; // bytes of HTTP headers on a typical request+response const WS_FRAME_OVERHEAD = 6; // bytes of WebSocket framing per message // --- Short polling: ask every 3s regardless of whether there's news --- const POLL_INTERVAL = 3; const pollRequests = WINDOW / POLL_INTERVAL; // fixed cadence const pollOverhead = pollRequests * HTTP_OVERHEAD; // worst-case latency: up to a full interval before news is seen console.log("SHORT POLLING (every 3s):"); console.log(` round-trips: ${pollRequests} (most carry no news)`); console.log(` header overhead: ${pollOverhead} bytes`); console.log(` worst-case latency to see news: up to ${POLL_INTERVAL}s`); // --- Long polling: one request per delivered message (+ re-ask), news is instant --- const longRequests = newsAt.length + 1; // one held request per news item, + the pending one const longOverhead = longRequests * HTTP_OVERHEAD; console.log("\nLONG POLLING:"); console.log(` round-trips: ${longRequests} (one per real message)`); console.log(` header overhead: ${longOverhead} bytes`); console.log(` latency to see news: ~instant`); // --- WebSocket: one handshake, then a cheap frame per message, pushed instantly --- const wsHandshake = HTTP_OVERHEAD; // the single Upgrade handshake const wsOverhead = wsHandshake + newsAt.length * WS_FRAME_OVERHEAD; console.log("\nWEBSOCKET:"); console.log(` handshakes: 1, then ${newsAt.length} tiny frames`); console.log(` total overhead: ${wsOverhead} bytes`); console.log(` latency to see news: ~instant, and the client can reply on the same pipe`); console.log("\nOverhead ratio vs WebSocket:"); console.log(` short polling spends ${(pollOverhead / wsOverhead).toFixed(1)}x the overhead of a WebSocket`); console.log(` long polling spends ${(longOverhead / wsOverhead).toFixed(1)}x the overhead of a WebSocket`);

Short polling burns a huge, fixed cost whether or not anything happens; long polling scales with the message count but pays full HTTP headers each time; the WebSocket pays one handshake and then almost nothing per message — and the client can talk back on the same connection. For a chatty, interactive workload the persistent connection wins by a mile.

Choosing — and the newest rungs

More power is not always the right answer; each tool fits a shape of problem.

Use…When…
Short/long pollingupdates are rare, or you must support ancient clients and can't hold connections
Server-Sent Eventsthe flow is one-way, server → client: notifications, live scores, log tails, progress feeds
WebSocketsboth sides talk continuously: chat, multiplayer games, collaborative editing, live cursors
WebRTCyou need direct peer-to-peer media (video/voice) or lowest-latency data, often bypassing the server
WebTransport (over QUIC/HTTP-3)the newest option: multiplexed, unordered-capable streams and datagrams over UDP-based QUIC — like WebSockets but without head-of-line blocking

SSE and WebSockets cover the vast majority of real-time web today. WebRTC is the choice when the payload is live audio/video and you want peers talking (nearly) directly to each other. WebTransport over QUIC is the emerging successor for cases where a single WebSocket's strict in-order TCP stream causes head-of-line blocking — QUIC's independent streams over UDP dodge it.

It is easy to picture a WebSocket as magic: the server just "pushes" and it appears, costing nothing. Three realities puncture that.

Plain HTTP is stateless, so any request can hit any server behind a load balancer — trivially horizontal. WebSockets break that: a connection is pinned to one specific server for its whole life, and that server holds the client's state. So when user A (connected to server 1) sends a chat message that must reach user B (connected to server 3), server 1 has no direct line to B. The standard fix is a pub/sub fan-out backend — a shared message bus (Redis pub/sub, Kafka, NATS, a managed service) that every app server subscribes to. Server 1 publishes the message to the bus; the bus delivers it to server 3; server 3 pushes it down B's socket. The persistent connections stay at the edge, and a horizontally-scalable message layer sits behind them doing the routing. Real-time at scale is less about the WebSocket itself and more about the fan-out plumbing behind it.