Skip to main content

WebSockets: The Two-Way Street

You Already Wrote the Cliffhanger

Last lesson ended on a loaded question. Long polling got us near-real-time on plain HTTP — but even at its best it pays a full HTTP round trip per message: the client asks, the server answers, the client immediately asks again. Under a busy chat, that's one request, with all its headers and cookies and auth, for every single message. So we asked: why does the client keep re-asking at all? What if you paid the HTTP tax once — one handshake — and then the line just… stayed open, both directions, forever?

That's not a rhetorical flourish. It's a real protocol, and it does exactly that. A WebSocket takes an ordinary HTTP connection, asks it to be upgraded, and on success the very same TCP socket stops speaking HTTP and starts speaking a tiny two-way frame protocol. The server finally gets a mouth — it can send the instant it has news, without being asked — and the client can talk back at the same time. The per-message tax that made long polling degenerate into short polling? Gone: a message becomes a 2-byte-header frame instead of a 400-byte request.

Here's the honest shape of this lesson, though, because the magic has a bill. The first half is how the trick works — the upgrade, the frames, the two directions, and one genuinely surprising detail about masking. The second half is what it costs: a WebSocket is stateful and long-lived, and that single fact reaches back and breaks almost every stateless-scaling assumption you built in §5. Beginners will leave able to explain what a WebSocket is; staff engineers will leave with the part everyone forgets — why one is so much harder to operate than to open.

A diagram of how a WebSocket is born from an ordinary HTTP request and then becomes a two-way pipe. On the left, the upgrade handshake: a client sends a normal-looking HTTP GET carrying the headers Upgrade: websocket, Connection: Upgrade, and a random Sec-WebSocket-Key; the server answers not with the usual 200 but with 101 Switching Protocols and a Sec-WebSocket-Accept value, computed as the base64 of the SHA-1 of the client key concatenated with a fixed magic string from the specification. A seam is drawn across the same connection line labeled: same TCP socket, it just stopped speaking HTTP and started speaking frames. The center shows the persistent pipe carrying traffic both ways at once — full duplex. Client-to-server frames flow rightward, each drawn as a tiny labeled byte-strip: a FIN-and-opcode byte, a mask-and-length byte, a four-byte masking key, and the payload shown scrambled because the client must mask every frame. Server-to-client frames flow leftward, the same tiny strips but unmasked, because a server must never mask. Beside one client frame sits the fat, greyed-out HTTP request it replaces, with the number that sells the whole idea: a five-character message is eleven bytes as a frame versus roughly four hundred and thirty as an HTTP request, about forty times less overhead — and the server can now push without being asked. On the right, three chips price the catch, because a WebSocket is stateful and long-lived: pinned to one server, so you need sticky sessions and least-connections balancing instead of round robin; cross-server broadcast needs a publish-subscribe backplane like Redis or Kafka or servers become islands; and a heartbeat of ping and pong frames, or the connection dies silently without either side knowing. The takeaway across the bottom: pay the HTTP tax once, and then the line stays open, both ways.

The Upgrade: HTTP's Secret Handshake

The surprising part is where a WebSocket begins: as a perfectly ordinary HTTP request. The client sends a GET, but with a few special headers asking to change protocols mid-connection:

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

The server, if it speaks WebSocket, answers with a status code you've probably never seen in the wild — not 200, but:

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

101 Switching Protocols is HTTP saying "understood — I'm handing this exact TCP connection over to a different protocol now." From this instant, the socket is a WebSocket. No new connection, no second port — the pipe you opened for a GET is quietly repurposed.

Two details carry real weight. First, why start with HTTP at all? For the same reason long polling survived a decade: an HTTP request on port 80 or 443 sails through every proxy, firewall, and corporate middlebox on Earth, because it looks like the web traffic they already allow. A WebSocket inherits that reach by being born from a normal request.

Second, that Sec-WebSocket-Accept value is not random and — this trips people up — it is not security. The server computes it by a fixed recipe: take the client's Sec-WebSocket-Key, glue on a magic constant string baked into the spec (258EAFA5-E914-47DA-95CA-C5AB0DC85B11), SHA-1 the whole thing, and base64 the result. The client knows the recipe too, so it checks the answer. Its only job is to prove "you are genuinely a WebSocket server, not some cache or proxy that blindly echoed 101 at an Upgrade header it didn't understand." It's a secret handshake, in the club-password sense — not a lock. (You'll compute this exact value, live, in the interactive.)

After 101: Frames, Not Requests

Here's the moment the whole lesson turns on. Once that 101 lands, the connection stops sending HTTP messages entirely. No more request lines, no more header blocks, no more cookies re-sent on every message. Instead, each message is a compact binary frame, and the frame header is tiny — as small as 2 bytes:

  • 1 bit — FIN: is this the last frame of the message? (Big messages can be split across frames.)
  • 4 bits — opcode: what kind of frame. 0x1 text, 0x2 binary, 0x8 close, 0x9 ping, 0xA pong. Those last three are the connection's own control language.
  • 1 bit — MASK + 7 bits — length: is the payload masked (more on that next), and how long is it. If the length needs more room, 2 or 8 extra bytes carry a 16- or 64-bit length.
  • Then the payload itself.

Put a number on the payoff, because the number is the argument. A 5-character message — "Hello" — travels as a frame of 11 bytes total: 2 header + 4 mask-key + 5 payload. The same message as an HTTP request — the request line, Host, Cookie, Authorization, User-Agent, Content-Type, and the rest — is roughly 430 bytes, and that's before the server's response headers come back. That's about 39× less overhead per message, and it compounds: on a chat with thousands of messages a second, you've stopped re-introducing yourself to the server on every word.

This is precisely the wall long polling hit. Its fatal flaw was one full HTTP round trip per message; the WebSocket pays that cost once, at the upgrade, and every message afterward is a frame. The client stopped having to ask.

Both Directions at Once — and the Mask

The other half of "two-way street" is full-duplex: after the upgrade, either side sends a frame whenever it wants, with no pairing. A server push is not a "response" to a request — there was no request. The client can be typing a frame at the exact moment a server frame is arriving, and neither waits for the other. If that shape feels familiar, it's the bidirectional streaming from the gRPC lesson — except WebSockets bring it natively to the browser, which gRPC couldn't. This independence is the point, and it's why a REST request/response mental model quietly misleads you here.

Now the genuinely surprising detail, the one that makes people say "wait, why?"masking. The rule is lopsided: every frame a client sends must be masked; every frame a server sends must not be. Masking means the client generates a random 4-byte key and XORs it byte-by-byte over the payload (masked[i] = payload[i] XOR key[i % 4]), sending the key along in the frame. XOR is self-inverse, so the server just XORs again to recover the text — no secret, the key is right there.

So if it isn't secrecy, what is it for? It's a defense against cache poisoning, discovered in the protocol's design phase (the classic "Talking to Yourself for Fun and Profit" analysis, ~2011). Imagine a dumb intermediary proxy that doesn't understand WebSocket frames. An attacker's browser could craft frame payloads whose raw bytes happen to spell out a valid-looking GET /some/victim/url — and trick the proxy into caching a poisoned response for a real URL. By forcing the client to XOR every frame with an unpredictable per-frame key, the on-the-wire bytes become impossible to steer, so the attacker can't smuggle a chosen request through the proxy. The server has no such problem (its frames aren't attacker-controlled), so it's spared the cost. Masking is a proxy-safety trick, not encryption — for actual confidentiality you use wss://, which is simply WebSocket over TLS (the same TLS from §5).

See It: Open the Line

Reading the handshake is one thing; watching your own message turn into masked frame bytes is another. So build a real one below and drive it.

Hit Connect and watch the upgrade fly: your client generates a random Sec-WebSocket-Key, and the server computes Sec-WebSocket-Accept live, with real crypto — the genuine base64-of-SHA-1 of your key plus the magic GUID — and answers 101. The pipe turns solid. Flip the server to "plain HTTP, doesn't speak WebSocket" and the accept won't match: the upgrade is refused, and you've just seen exactly what that handshake is for.

Then talk. Type anything and watch it become a frame in real time — the FIN/opcode byte, the mask/length byte, the random 4-byte key, and your text XOR-masked character by character — fly across, and the server's reply come back unmasked. Both lanes move independently, so type while a server push is in flight and feel the full-duplex. A live meter tallies the byte payoff your own messages rack up (frame vs HTTP request); the ping/pong and close buttons let you speak the control opcodes. And when you're ready for the hard part, add a second server and watch a message to a buddy over there vanish — until you switch on the pub/sub backplane — then kill a server and stop the heartbeat to meet the three ways a stateful connection bites back.

Open the Line — build a real WebSocket from an ordinary HTTP request and then drive it, byte for byte. Hit Connect and watch the upgrade happen: your client sends an HTTP GET carrying a freshly generated Sec-WebSocket-Key, and the server computes the Sec-WebSocket-Accept live — the actual base64 of the SHA-1 of your key plus the specification's magic string, done with real Web Crypto, not faked — and answers 101, at which point the same socket turns solid and persistent. Flip the server to a plain HTTP server that does not speak the protocol and the accept no longer matches, so the upgrade is refused; that check is the whole reason the handshake exists. Then talk. Type any message and watch it become a frame in front of you: the FIN and opcode byte, the mask-and-length byte, a random four-byte key, and your text XOR-masked byte by byte, because a client must mask every frame — while the server's replies arrive unmasked, because a server must not. Both directions animate independently, so you can type while a server push is mid-flight and feel what full-duplex actually means. A running meter tallies the payoff your own messages generate — an eleven-byte frame against a four-hundred-byte HTTP request — and opcode buttons let you fire a ping and get a pong, or send a close. Finally, meet the catch: add a second server and watch a message to a buddy connected there vanish, until you switch on the pub/sub backplane; kill a server and watch every socket drop at once and reconnect with jitter; stop the heartbeat and watch the connection look alive while pushes quietly disappear.

The Bill: A Socket Is Stateful

Everything so far is why WebSockets are wonderful. This is why they're hard, and it's one sentence: the connection is long-lived and stateful. Remember the stateful-versus-stateless fork from §3? A WebSocket lands hard on the stateful side, and that reaches back and breaks the stateless-scaling world you built in §5. Four consequences, each a real 3 a.m. page:

  • It's pinned to one server. A WebSocket lives on the exact machine that answered its handshake, for its entire life — minutes, hours, days. The stateless load balancer that could send each HTTP request anywhere (§5) now can't move you at all. Round-robin is the wrong algorithm — connections never end, so counts drift and one box ends up buried — you want least-connections. And you need sticky routing so a client's frames keep reaching its server. Sticky is a pragmatic start and a growing liability: when that server dies, every client on it drops at once; a rolling deploy means draining a box, which means deliberately disconnecting everyone on it.
  • Servers become islands. Client A is on server 1, client B on server 2, same chat room. A sends a message — and B never hears it, because B's socket lives on a different machine that knows nothing about it. The fix is a pub/sub backplane (Redis, Kafka, NATS): each server publishes every message to the shared bus and fans out to its own local sockets. Without it, horizontal scaling silently breaks the moment you add the second server.
  • The connection can die without telling you. A TCP socket can go silently dead — a phone sleeps, a NAT times out, Wi-Fi switches to cellular — and both ends still think it's open (this is L1's ghost connection, now permanent). You'd push frames into a void. The only cure is a heartbeat: ping/pong frames every ~30 seconds; miss a few pongs and you declare the peer dead and clean up. And when clients reconnect after a blip, they do it with exponential backoff + jitter — the exact thundering-herd fix from L1, because 50,000 sockets dropping and reconnecting in the same instant is the same stampede.
  • Count is the cost, not traffic. An idle WebSocket is nearly free — a few kilobytes of buffers, no bytes moving. But a single tuned server holding 500,000 of them is real memory, and it must be an event-loop server (§2), not thread-per-connection — 500k threads is a non-starter. And when one client reads slower than you're pushing, frames pile up in its send buffer: backpressure you have to handle, or one slow phone leaks memory on your server.

None of this appears in "open a WebSocket in 3 lines" tutorials. It's the entire difference between a demo and a system, and it's why teams reach for a WebSocket only when the two-way, chatty nature genuinely earns all of this.

The three operational costs of a WebSocket being a long-lived, stateful connection instead of a one-shot request. First, it is pinned to one server, so the load balancer needs sticky sessions and least-connections balancing rather than round robin, and any reconnect must land on a box that still holds the connection state. Second, cross-server broadcast needs a pub/sub backplane such as Redis or Kafka between your servers, or each server is an island that can only reach its own sockets. Third, liveness needs a heartbeat of ping and pong frames, because otherwise the socket dies silently behind NAT or a mobile network and the server keeps sending into the void.

When a Two-Way Street Is the Wrong Street

Because it costs all that, the sharpest WebSocket skill is knowing when not to use one:

  • Reach for a WebSocket when the conversation is genuinely two-way and chatty: chat with typing indicators, multiplayer game state, collaborative editing (Figma, Google Docs cursors), live trading, presence ("3 people viewing"). Both sides send, independently, often. This is what full-duplex is for.
  • Don't, when the data flows one way — server to client only: a stock ticker, notifications, a live score, a progress bar, an activity feed. You're using a two-way street for one-way traffic and paying the full stateful bill for a direction you never use. There's a lighter, HTTP-native tool built exactly for server-to-client push — and it's the next lesson. (Foreshadowing on purpose.)
  • Don't, when updates are rare or freshness is loose: a background job's status, a dashboard that refreshes each minute. You already have the tool — poll (L1). A held connection for something that changes hourly is pure overhead.
  • Don't, for plain request/response: fetching a page, submitting a form, CRUD. That's not real-time at all; that's §6, and a REST call is simpler, cacheable, and stateless.

The instinct to reach for the fanciest tool is exactly backwards. A WebSocket is a commitment — to statefulness, to a backplane, to heartbeats, to careful deploys. Make that commitment only when the problem is truly a two-way street.

Mental-Model Corrections

  • "WebSockets replace HTTP." No — a WebSocket is born from HTTP (the upgrade) and only serves the genuinely bidirectional slice of your app. The rest is still request/response. WS is a specialized tool you reach for, never the default transport.
  • "An open connection is constantly sending, so it's expensive." An idle socket is nearly free — no traffic, a few KB. The cost is count and statefulness (pinning, backplane, heartbeats), not bandwidth.
  • "Masking makes WebSockets secure." Masking defends dumb proxies against cache poisoning; the key rides inside the frame, so it's no secret. Confidentiality is wss:// (TLS). Two entirely different problems.
  • "Just put the WebSocket behind our normal load balancer." That LB was built for short stateless requests. Long-lived connections need least-connections (not round-robin), idle timeouts longer than your heartbeat, sticky routing, and — the instant you run more than one server — a pub/sub backplane, or clients on different boxes can't hear each other.
  • "Full-duplex is just faster request/response." There's no pairing at all. Either side sends whenever it likes; a server push answers no request. That independence is the whole point, and the REST mental model quietly misleads you.
  • "If the socket's open, both sides know it's alive." TCP dies silently (sleep, NAT, network switch). Only a heartbeat exposes a half-open connection; without ping/pong you're confidently pushing frames into nothing.

Try It Yourself: Build a Frame by Hand

No libraries, no browser — just Node and about 25 lines that make the whole protocol concrete. You'll compute the real handshake proof and watch your own text become masked frame bytes. Save as wsframe.js:

// wsframe.js — a WebSocket handshake + frame, BY HAND. No dependencies.
const crypto = require('crypto');
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';   // the magic constant from RFC 6455

// 1) the proof a server must return for a given client key
const key = crypto.randomBytes(16).toString('base64');
const accept = crypto.createHash('sha1').update(key + GUID).digest('base64');
console.log('client Sec-WebSocket-Key   :', key);
console.log('server Sec-WebSocket-Accept:', accept, ' = base64(sha1(key + GUID))');

// 2) encode a MASKED client text frame
function encode(msg) {
  const payload = Buffer.from(msg);
  const mask = crypto.randomBytes(4);
  const masked = Buffer.from(payload.map((b, i) => b ^ mask[i % 4]));
  const header = Buffer.from([0x81, 0x80 | payload.length]); // 0x81 = FIN+text ; 0x80 = MASK bit + length
  return Buffer.concat([header, mask, masked]);
}
const frame = encode('Hello');
console.log('\n"Hello" on the wire :', frame.toString('hex'), `(${frame.length} bytes)`);
console.log('same message as HTTP ≈ 430 bytes →', (430 / frame.length).toFixed(0) + '× less overhead');

// 3) decode it — proves XOR masking is self-inverse
function decode(f) {
  const len = f[1] & 0x7f, mask = f.slice(2, 6), payload = f.slice(6, 6 + len);
  return Buffer.from(payload.map((b, i) => b ^ mask[i % 4])).toString();
}
console.log('server unmasks it back to:', JSON.stringify(decode(frame)));

Run node wsframe.js. You should see something like:

client Sec-WebSocket-Key   : WBcsxvQ3hFJ4EvSlP43PfA==
server Sec-WebSocket-Accept: Xk7FrvCpLL+NCGioPO5X4eBTnGU=  = base64(sha1(key + GUID))

"Hello" on the wire : 8185ba96dd85f2f3b1e9d5 (11 bytes)
same message as HTTP ≈ 430 bytes → 39× less overhead
server unmasks it back to: "Hello"

Read those 11 bytes: 81 is FIN + opcode 0x1 (text); 85 is MASK bit + length 5; the next 4 bytes are the random mask key; the last 5 are "Hello" XOR-masked into gibberish — and step 3 XORs it right back. That's the entire wire format, in your terminal.

Then watch a live one. In any browser's DevTools console:

const ws = new WebSocket('wss://echo.websocket.org');
ws.onopen = () => ws.send('hello from the two-way street');
ws.onmessage = (e) => console.log('server said:', e.data);

It connects, sends, and the echo comes back on the same open socket with no second request. Now open DevTools → Network → click the ws connection → Messages and watch the frames flow both ways, live — the exact bytes you just built by hand, on a real connection.

Recap & What's Next

  • A WebSocket is HTTP's trapdoor into a two-way pipe. It starts as a GET with Upgrade: websocket; the server answers 101 Switching Protocols with Sec-WebSocket-Accept = base64(SHA1(key + GUID)) — a do-you-really-speak-this proof, not security — and the same socket becomes a WebSocket.
  • Then it's frames, not requests. A 2-byte header (FIN, opcode, mask, length) instead of hundreds of bytes of HTTP — "Hello" is 11 bytes vs ~430, ~39× less — and the server can finally push without being asked. This is the per-message tax that broke long polling, paid once.
  • Full-duplex means no pairing: either side sends anytime. And the lopsided masking rule (clients must, servers must not) is a cache-poisoning defense on dumb proxies, not encryption — that's wss://.
  • The bill is statefulness. A long-lived connection is pinned to one server (sticky + least-connections, not round-robin), needs a pub/sub backplane so servers aren't islands, needs a heartbeat because TCP dies silently, and costs you in connection count and backpressure, not bandwidth. This is the §3 stateful fork and the §5 scaling story, come back to collect.
  • Use it only for a genuine two-way street — chat, multiplayer, collaboration. For one-way pushes, rare updates, or plain CRUD, cheaper tools win.

That "one-way push" exception is the whole next lesson. So often, the client has nothing to say back — it just wants a live stream of updates from the server: a stock price, a notification, tokens from an LLM. Standing up a full stateful, masked, bidirectional WebSocket for a one-way trickle is bringing a freight truck to carry a letter. There's a tool that does exactly server-to-client push, over ordinary HTTP, with automatic reconnection built in — no upgrade, no frames, no masking. That's Server-Sent Events, and it's where we go next.