Under the Hood
Networking

WebSockets & realtime under the hood

Every request you've sent so far, the client started. So how does a chat message the server just received reach your screen without you asking? First the ugly workarounds — short polling and long polling, and what each actually costs. Then the real WebSocket upgrade handshake (the actual Upgrade headers and the 101 response, including why Sec-WebSocket-Accept exists), frames and masking, and why silent proxies kill idle connections so you need ping/pong heartbeats. Then what Socket.IO adds on top — rooms, acks, auto-reconnect, the polling-to-websocket upgrade dance — and what that convenience costs. Finally the two hard problems realtime forces on you: catching up on messages you missed while offline, and fanning a message out across more than one server.

WebSockets & realtime under the hood

Every lesson in this track has traced the same shape: you send a request, the server sends a response. The client starts, always. That model built the entire web, and it has one hole you hit the instant you try to build a chat app — the server can't start anything. A message arrives for you on the server right now. The server has your data and wants to hand it over. It cannot. It has no open line to your screen; it can only sit on the message and wait for you to ask. In what happens when you fetch() the whole dance began with your fetch call, and HTTP has no move where the server speaks first. This lesson is about closing that hole: the workarounds people suffered through, the protocol that finally fixed it, and the two problems that show up the moment "realtime" meets real networks.

The itch: HTTP can't push

Say you're on a group's chat screen. A friend sends "landed, grabbing bags." Their message hit the server 40 milliseconds ago. On your phone, nothing — because your phone hasn't asked. The request/response model has no channel for the server to volunteer information. Everything below is people trying to fake one on top of a protocol that fundamentally doesn't have it.

Short polling is the caveman fix: ask again, on a timer. Every 3 seconds your client fires GET /groups/123/messages?since=.... Most of those responses come back empty — nothing new happened in the last 3 seconds — but you paid for the full round trip anyway: a request, TLS work if the connection wasn't warm, a server hit, a response. The cost is request rate times mostly-empty responses. A thousand users polling every 3 seconds is ~333 requests a second whether or not anyone said anything, and your latency floor is the poll interval — a message can sit up to 3 seconds before the next poll collects it. Poll faster to cut latency and you multiply the wasted requests; poll slower to cut load and chat feels laggy. There's no good point on that dial.

Long polling is the clever fix. The client makes a request, and the server doesn't answer immediately — it holds the request open until it actually has something to send, then responds. The client, on receiving that response, immediately opens another held request. Now a message reaches you the instant the server has it, not on the next tick, and idle time costs no responses. Better — but look at what it costs. Each message still rides its own full request/response cycle: roughly one message per round trip, plus the connection churn of constantly reopening. And you can't hold a request forever — proxies and load balancers time connections out — so the server has to let go every ~30–60 seconds with an empty response just so the client can re-establish, which is polling's waste sneaking back in through the side door. Long polling was the backbone of "realtime" web for years, and it was always a hack pretending HTTP could do something it couldn't.

The honest fix isn't a smarter way to poll. It's a different kind of connection.

The WebSocket upgrade: turning one HTTP request into a two-way pipe

A WebSocket is a single, long-lived, full-duplex connection — both sides can send whenever they want, no request needed — over the same TCP connection an HTTP request would have used. The clever bit is how you get one: you don't open a new kind of socket, you ask an ordinary HTTP server to upgrade an ordinary HTTP request. Here's the actual handshake on the wire:

GET /socket HTTP/1.1
Host: api.hisaab.measdev.me
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

This is a normal GET. What makes it special is Connection: Upgrade and Upgrade: websocket — the client saying "I'd like to stop speaking HTTP on this connection and switch to the WebSocket protocol." If the server agrees, it answers not with the usual 200 but with:

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

101 Switching Protocols — a status code you'll almost never see anywhere else — means "agreed, from the next byte on this is a WebSocket, not HTTP." The TCP connection stays open and both sides now speak the WebSocket framing protocol over it. No more request/response; it's a bidirectional pipe.

That Sec-WebSocket-Accept value isn't decoration. The client sent a random Sec-WebSocket-Key; the server must take that exact string, append the fixed "magic" GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11, SHA-1 hash the result, and base64-encode it — and echo that back. The client recomputes it and checks. Why this ritual? Two reasons. First, it proves the server actually speaks WebSocket and didn't just happen to return 101 for some unrelated reason — only a real WebSocket server knows to do the key-plus-GUID computation, so a matching Accept is proof of a genuine upgrade. Second, it defeats caching intermediaries: a dumb proxy that saw a GET and cached a stored response can't possibly produce the correct Accept for this connection's unique random key, so the handshake fails loudly instead of a stale cached page silently masquerading as a live socket.

Frames, masking, and why quiet connections die

Once upgraded, data doesn't travel as HTTP messages — it travels as frames. A frame has a small header, 2 to 14 bytes, then the payload. The header includes an opcode naming what kind of frame it is:

FrameOpcodeMeaning
text0x1UTF-8 text payload (your JSON message)
binary0x2raw bytes (a file chunk, a protobuf)
close0x8"I'm closing this connection," optionally with a reason code
ping0x9"still there?" — expects a pong back
pong0xAreply to a ping

Compare that to the hundreds of bytes of repeated HTTP headers on every polled request — a WebSocket text frame carrying a chat message is a couple of header bytes plus the JSON. That efficiency is the payoff.

One rule surprises people: every frame a client sends to a server must be masked — the client XORs the payload with a random 4-byte key included in the frame, and the server unmasks it. Server-to-client frames are not masked. This isn't encryption (the key travels in plain sight right next to the data; TLS is what encrypts). It exists to defeat a specific attack: without masking, a malicious page could send crafted bytes that, passing through an old proxy that half-understood the traffic, looked like a valid HTTP request and poisoned the proxy's cache. Randomizing the client's bytes makes them un-forgeable as HTTP, so the attack fails. It's a scar from the era of proxies that peeked inside connections they didn't understand.

Now the failure mode that bites everyone building realtime:

What Socket.IO adds — and what it costs

Raw WebSockets give you a pipe and nothing else: no reconnection, no way to address a subset of clients, no confirmation a message arrived. Socket.IO is a library that wraps WebSockets and hands you the parts you'd otherwise build by hand:

  • Rooms — a server-side grouping so you can emit to "everyone in group 123" instead of tracking every socket yourself.
  • Acks — an emit can carry a callback that fires when the other side confirms receipt, giving you request/response semantics back on top of the pipe when you want them.
  • Auto-reconnect — when the connection drops (and on mobile it will, constantly), the client retries with backoff automatically instead of leaving you disconnected.
  • The transport upgrade dance — Socket.IO (via its engine, engine.io) often starts on HTTP long polling to get a connection up through hostile networks immediately, then quietly upgrades to a real WebSocket once it confirms one can be established. You get a connection that works even where WebSockets are blocked, and the fast pipe where they aren't.

None of this is free. Socket.IO frames your messages inside its own protocol on top of the WebSocket frame — extra bytes, and a wire format only a Socket.IO client can read, so you can't point a plain browser WebSocket or a generic tool at it. The reconnect and polling-fallback machinery is real code and state on both ends. It's an excellent trade for most apps, but "I'll just use Socket.IO" is adopting a protocol, not just a socket.

The two hard problems realtime forces on you

A pipe that pushes messages is the easy 80%. The last 20% is where realtime gets genuinely hard, and both problems come from the network being real.

Problem one: the messages you missed while disconnected. Your socket drops — subway tunnel, cell handoff, the idle-kill above. Three messages arrive for you during those eight seconds. Auto-reconnect brings the socket back, but those three messages were pushed to a connection that no longer existed; they're gone from the realtime channel. If you rely on the socket alone, you have a silent hole in the conversation and no way to know. The fix is to stop treating the socket as the source of truth and give every message a monotonic id or sequence number. The client remembers the id of the last message it actually saw. On reconnect, before trusting the live stream again, it asks the server over a normal HTTP call: "give me everything after id X" — a catch-up query. The server replays the gap. And because a message might arrive both in the catch-up response and on the live socket (a race at the seam), the client must dedup by id — drop anything it's already rendered. Cursor-based catch-up plus id dedup is what makes a chat survive a flaky connection instead of quietly losing messages.

Problem two: fan-out across more than one server. A WebSocket is a stateful, long-lived connection that lives in the memory of exactly one server process — the one that accepted it. That's fine on a single box. But the moment you run two servers for capacity (the load-balancing lesson explained why realtime forces sticky sessions), you hit a wall: user A's socket is on server 1, user B's socket is on server 2, and A posts to a room B is in. Server 1 receives it — but server 1 has no socket for B; B is a stranger to it. B hears nothing. A socket living on one server can only reach clients connected to that same server. The fix is a pub/sub bus the servers share: server 1 publishes "message for room 123" to the bus, every server subscribes, server 2 receives it and pushes down its own local sockets — so B gets the message. In the Socket.IO world this is the Redis adapter: Redis is the pub/sub bus, and it turns N independent socket servers into one logical broadcast domain. You don't need it for one server; you need it the instant there are two.

Go deeper

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. A message arrives on the server for a user who is idle on the chat screen. With plain HTTP request/response, why can the server not deliver it, and what does long polling change about the request lifecycle to work around this?
  2. Long polling delivers a message the instant the server has it, yet it is still called a hack. What does it cost per message compared to a WebSocket, and why must the server periodically respond with nothing even when there is no new data?
  3. The WebSocket handshake makes the server echo a Sec-WebSocket-Accept computed from the client key plus a fixed GUID. What two distinct problems does that echoed value solve, and why would a cached response from a dumb proxy fail the check?
  4. A user reports the app "randomly stops getting messages until I reopen it," but the socket never reports an error. Explain the middlebox behaviour that causes this, and which WebSocket frames are the fix.
  5. A user loses signal for eight seconds and three messages are pushed during the gap. Socket.IO auto-reconnects the socket. Why are those three messages still missing, and what mechanism recovers them without showing duplicates?
  6. You add a second Node process behind sticky sessions to handle more chat load. User A on server 1 posts to a room; user B, connected to server 2, hears nothing. Why does the socket alone fail here, and what component fixes it?