cf.completefrontend
My progress
FUNDAMENTALS · MODULE 5

WebSockets

One long-lived connection, opened by an HTTP handshake, on which browser and server can each send a message at any moment. The usual pick for chat, multiplayer, and collaboration, and the one where you own the protocol, reconnects, and scaling.

The problem: both sides need to talk

You are building a team chat app. When Maya presses Enter, her message must appear on Leo's screen within about 100 ms. While Leo types a reply, Maya should see "Leo is typing". Both of them have a green dot next to their names that turns grey a few seconds after they close the tab.

Look at the traffic this creates. Messages flow from server to browser (new messages, typing, presence) and from browser to server (send, typing, read receipts) all the time, in small pieces of 50 to 300 bytes. A busy channel can produce several messages per second per user, in both directions.

What plain HTTP gives you

HTTP is a request and an answer. The browser speaks first, and the server can only reply. The earlier lessons showed three ways to bend that rule: short polling asks on a timer, long polling holds the request open until there is news, and server-sent events (SSE) keep one response open and stream events down it.

Those work well for traffic that flows mostly one way. For chat, every send is still its own POST: 500 to 800 bytes of headers and cookies to carry a 60-byte message, and with HTTP/1.1, a possible wait for a free connection. Typing indicators are worse: one request per keystroke burst, for data that is stale a second later.

A WebSocket removes the request model altogether. The browser opens one TCP connection, upgrades it with a single HTTP handshake, and from then on both sides can send a message whenever they like. Each message carries only 2 to 14 bytes of framing. There are no headers per message, no cookies per message, and no waiting for a turn.

Two sequence diagrams side by side. Left, plain HTTP: the browser sends POST /send and gets 200 OK, then sends GET /poll and gets 200 with one message: four separate exchanges, each with full headers, about 3 KB in total. Right, a WebSocket: after one handshake, the browser sends a chat message, the server pushes a new message, the browser sends a typing signal, and the server pushes another typing signal, each a frame of only 2 to 6 bytes of overhead.HTTP: request and answerWebSocket: anyone, any timeBrowserServerPOST /send200 OKGET /poll200 + 1 messageBrowserServersend: Lunch?message.newtypingtyping4 requests, about 3 KB headers1 socket, 2 to 6 bytes a frame
With HTTP, the browser must ask before the server can answer, and every exchange carries full headers. On a WebSocket, either side sends whenever it has something, and each message costs a few bytes of framing.

What it costs you

The price is that the browser stops doing work for you. With HTTP, every request is independent, so a load balancer can send it anywhere, a cache can answer it, and a failed request is simply retried. With a WebSocket, you hold an open connection to one particular server for minutes or hours, and that changes your whole design:

  • You design the protocol. The connection carries bytes in messages, nothing more. Message types, ids, acknowledgements, and errors are all yours to define.
  • You handle the drops. Phones switch networks, laptops sleep, servers deploy. WebSocket never reconnects on its own, unlike EventSource.
  • You scale state, not requests. A server holding 50,000 open connections must know which of them care about channel 42, and a message published on another server must reach them.
  • You inherit fewer defaults. No HTTP caching, no status code per message, and only part of the usual HTTP security tooling.

The chapters below take these one at a time, from the handshake bytes to scaling, security, and what comes after WebSockets. The polling and SSE lessons are linked, not repeated.

The mental model: a phone call, not letters

HTTP is like sending letters. Every letter needs a full envelope with addresses and stamps (the headers), and you only get a letter back after you send one. A WebSocket is a phone call. Dialing takes a moment, but once the line is open, either person can speak at any time, and each sentence costs almost nothing.

The comparison also shows the costs. A call ties up the line while it lasts, a dropped call can hide in the silence, and someone has to call back and ask "where were we?". Holding connections, detecting silence, and resuming are most of this lesson.

Four layers, each with one job

It helps to see a WebSocket as a stack of layers. Each layer solves one problem and knows nothing about the one above it:

A stack of four layers, top to bottom. Your app protocol, with JSON envelopes, ids, acks and sequence numbers, which you design. The WebSocket protocol, RFC 6455, with the handshake, frames and close, built into browsers. TLS, encryption for wss URLs, the same as HTTPS. TCP, reliable ordered bytes, provided by the operating system.Your app protocolJSON envelopes, ids, acks, seqyou design thisWebSocket (RFC 6455)handshake, frames, closebuilt into browsersTLSencryption for wss://same as HTTPSTCPreliable, ordered bytesthe operating system
Each layer solves one problem. TCP keeps bytes in order, TLS hides them, WebSocket cuts them into messages, and only the top layer gives them meaning. That top layer is the one you have to build.
  1. TCP gives a reliable, ordered stream of bytes between two machines. If a packet is lost, TCP resends it, and the bytes arrive in order.
  2. TLS encrypts that stream. A wss:// URL means TLS, just like https://. A ws:// URL means no encryption.
  3. The WebSocket protocol (RFC 6455) does two things. It starts with one HTTP request that switches the connection over (the handshake), and then it cuts the byte stream into messages, so the receiver knows where one message ends and the next begins.
  4. Your application protocol says what the messages mean: "send this chat message", "Leo is typing", "you have seen up to message 1042". The browser knows nothing about this layer. You design it.

Most WebSocket bugs live in the top layer, and most WebSocket surprises live in the bottom two. A message is never lost or reordered inside a healthy connection, because TCP guarantees it. But when the connection dies, everything that was in flight is gone, and the WebSocket layer does not tell you which messages made it.

Messages, not a stream

One property makes WebSockets pleasant to use: you send and receive whole messages. If the server sends a 40 KB JSON document, your message event fires once with all 40 KB, even if TCP delivered it in 30 packets. You never stitch partial chunks together, which you do have to do with a streaming fetch body.

The other property to remember: the connection is full duplex. Sending and receiving are independent. The server can push 10 messages while your one send is still on its way. That is why your protocol needs ids to match a reply to its request, as you will see in the protocol chapter.

The handshake, byte by byte

In its classic form, every WebSocket starts as an ordinary HTTP/1.1 GET request. This is deliberate: it lets WebSockets use port 443, pass through the same load balancers and firewalls as web traffic, and share a server with your normal pages. The request asks the server to switch protocols on this connection. If the server agrees, the connection stops being HTTP: no caching, no status codes, no per-request headers.

A sequence diagram between the browser and the server. First TCP and TLS set up the connection. The browser sends GET /v1/socket with Upgrade: websocket and a Sec-WebSocket-Key. The server answers 101 Switching Protocols with Sec-WebSocket-Accept. After that the connection speaks WebSocket frames: the browser sends hello with lastSeq 1042, the server sends events 1043 and 1044, the browser sends message.send, and the server sends an ack.BrowserServerTCP + TLS setupHTTPGET /v1/socket Upgrade: websocket101 Switching Protocolsfrom here on: frames, not HTTPframeshello lastSeq: 1042event 1043, event 1044message.send id: r_40ack id: r_40 seq: 1045
Only the first exchange is HTTP. After the 101, the same TCP connection carries frames, and either side can send first: the server pushes events 1043 and 1044 without being asked.

The request

When your code runs new WebSocket("wss://chat.example.com/v1/socket"), the browser opens a TCP connection, does the TLS handshake, and sends this:

http
GET /v1/socket HTTP/1.1
Host: chat.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: Y29tcGxldGVmcm9udGVuZA==
Sec-WebSocket-Protocol: chat.v2, chat.v1
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
Origin: https://app.example.com
Cookie: session=8f3a...
The upgrade request. The browser writes every one of these headers itself; your code can only choose the URL and the protocol list.
  • Upgrade: websocket and Connection: Upgrade ask to switch this connection to another protocol.
  • Sec-WebSocket-Version: 13 is the only version in use today.
  • Sec-WebSocket-Key is 16 random bytes, base64-encoded, new for every connection. Here we used the 16 readable bytes completefrontend so you can check the math.
  • Sec-WebSocket-Protocol lists the subprotocols the client can speak, in order of preference. This is how you version your application protocol.
  • Sec-WebSocket-Extensions offers extensions. permessage-deflate compresses each message.
  • Origin is the page that opened the socket. The server must check it (see the security chapter). Cookies for the host go along as usual.

The response: 101 Switching Protocols

http
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: pj5YwqTkNHwQ6kddkNoHomx7jlM=
Sec-WebSocket-Protocol: chat.v2
Sec-WebSocket-Extensions: permessage-deflate
The server agrees, picks one subprotocol and accepts compression. Right after the blank line that ends these headers, both sides speak WebSocket frames.

Status 101 Switching Protocols is the only success. Any other status, a 200, a 302 redirect, a 401, means the handshake failed. If the page offered subprotocols, the server must echo exactly one of them. If it picks one the client did not offer, or answers with none, the browser fails the connection.

Computing Sec-WebSocket-Accept

The server proves it really understood the WebSocket request by transforming the key. It appends a fixed string, the GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 from the spec, hashes the result with SHA-1, and base64-encodes the 20-byte hash. The browser does the same and compares.

Four steps from key to accept. Step 1, the Sec-WebSocket-Key from the request: Y29tcGxldGVmcm9udGVuZA==. Step 2, append the fixed GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11. Step 3, the SHA-1 hash of that string, 20 bytes: a63e58c2 a4e4347c 10ea475d 90da07a2 6c7b8e53. Step 4, base64 of the hash, pj5YwqTkNHwQ6kddkNoHomx7jlM=, which is the Sec-WebSocket-Accept value.1. Sec-WebSocket-Key from the requestY29tcGxldGVmcm9udGVuZA==2. append the fixed GUID from RFC 6455key + 258EAFA5-E914-47DA-95CA-C5AB0DC85B113. SHA-1 of that string: 20 bytesa63e58c2 a4e4347c 10ea475d 90da07a2 6c7b8e534. base64: the Sec-WebSocket-Accept valuepj5YwqTkNHwQ6kddkNoHomx7jlM=
Both sides run the same four steps. The browser accepts the 101 only if the server's value matches its own, which proves the server read this request's key.
js
import { createHash } from "node:crypto";

const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
const key = "Y29tcGxldGVmcm9udGVuZA==";

const accept = createHash("sha1").update(key + GUID).digest("base64");
console.log(accept); // "pj5YwqTkNHwQ6kddkNoHomx7jlM="
Run it in Node and you get the same value as the response above. The spec's own example key, dGhlIHNhbXBsZSBub25jZQ==, gives s3pPLMBiTxaQ9kYGzzhZRbK+xOo=.

This is not security. SHA-1 is used only as a fingerprint, and the key is not secret. It exists so that a plain HTTP server, or a cache that replays an old response, can never accidentally look like a WebSocket server. Only a server that read this request's key can produce the right answer.

When the handshake fails

The browser hides the details of a failed handshake from JavaScript on purpose, so a page can't use WebSockets to probe other servers. Your code sees an error event, then a close event with code 1006 and an empty reason. The real status, say a 403 because the origin was rejected, is only visible in the Network panel of the dev tools.

That has a practical consequence: if your server rejects a connection because a token expired, the client can't tell that apart from the server being down. A common fix is to accept the handshake and then close with an application close code (4000 to 4999) and a reason, such as 4401 "token expired", which the client can read.

Check your understanding · Exercise 1

Which statements about the WebSocket handshake are true? Select all that apply.

Frames on the wire

After the 101, everything on the connection is a frame: a small header followed by a payload. A message is one frame, or several frames if the sender splits it. You never build frames in the browser, but knowing their shape explains the overhead numbers, the 125-byte limit on pings, and why a client can't skip masking.

The layout of a WebSocket frame. The first two bytes hold FIN, 1 bit, three reserved bits, a 4-bit opcode, the MASK bit, and a 7-bit payload length. Then come an extended length of 0, 2 or 8 bytes, a masking key of 0 or 4 bytes, and the payload. Example: the client sends hi as 8 bytes, hex 81 82 for the header, 37 fa 21 3d for the mask key, and 5f 93 for the masked payload. The server would send the same message as 4 bytes: 81 02 68 69.first 2 bytes, bit by bitFINRSVopcodeMlength (7 bits)byte 0byte 1then, depending on those bitslength: 0, 2, 8 Bmask: 0 or 4 Bpayloadthe client sends hi: 8 bytes on the wire818237fa213d5f93headermask keyh, i XOR maskfrom the server: 81 02 68 69 (4 bytes)
0x81 is FIN plus opcode 1 (text). 0x82 is the mask bit plus length 2. The payload h i (68 69) is XOR-ed with the key, so it travels as 5f 93.

The header, field by field

  • FIN (1 bit): this is the last frame of the message. Almost every message is a single frame with FIN set.
  • RSV1 to RSV3 (3 bits): reserved for extensions. permessage-deflate sets RSV1 on compressed messages.
  • Opcode (4 bits): what kind of frame this is (table below).
  • MASK (1 bit): whether a 4-byte masking key follows. Set on every frame from the client, never on frames from the server.
  • Payload length (7 bits): 0 to 125 is the length itself. 126 means the next 2 bytes hold the length (up to 65,535). 127 means the next 8 bytes hold it.
  • Masking key (4 bytes, client frames only), then the payload.

So a frame header is 2 bytes for a short message from the server and 6 bytes for the same message from the client. The worst case is 14 bytes: 2, plus 8 for a huge length, plus 4 for the mask. Compare that with 500 to 800 bytes of HTTP headers per request.

Opcodes 0x3 to 0x7 and 0xB to 0xF are reserved. Opcodes 0x8 and above are control frames: a payload of at most 125 bytes, never split, and allowed between the pieces of a split message.
OpcodeNameWhat it carries
0x0ContinuationThe next piece of a message that was split into several frames.
0x1TextA message that must be valid UTF-8. The browser gives you a string.
0x2BinaryRaw bytes. The browser gives you a Blob or an ArrayBuffer.
0x8CloseStarts or answers the closing handshake: a 2-byte close code and an optional reason.
0x9Ping"Are you there?" The other side must answer with a pong.
0xAPongThe answer to a ping, echoing its payload.

Text or binary?

Calling socket.send("hello") sends a text frame; sending an ArrayBuffer, a typed array, or a Blob sends a binary frame. The receiver must check that text frames are valid UTF-8 and close the connection with code 1007 if not. Binary frames are not checked at all.

On the receiving side, socket.binaryType decides what a binary frame becomes: a Blob by default, or an ArrayBuffer if you set it to "arraybuffer", which is what you want for parsing. The protocol chapter compares JSON with binary encodings.

Masking: why the client scrambles its bytes

Every client frame is XOR-ed with a fresh random 4-byte key: payload byte i becomes byte XOR key[i mod 4]. This is not encryption, since the key is sent right in front of the payload. It protects other people's infrastructure.

While the protocol was still a draft, researchers showed that some old transparent proxies, which don't understand the upgrade, would read a page's WebSocket bytes as a new HTTP request. A malicious page could then send bytes that look like GET /app.js plus a fake response, and poison the proxy's cache for everyone behind it.

Because the page can't choose or predict the mask, it can't control the bytes on the wire. A server must close the connection if a client frame arrives unmasked, usually with code 1002 (protocol error). Server frames are never masked, because the page can't control what the server sends.

Sent by
The frame for a 2-byte text message sent by the browser: 8 bytes in total, of which 6 are framing. First bytes in hex: 81 82 37 fa 21 3d 5f 93.818237fa213d5f93payload 2 B + framing 6 B = 8 Bclient frame: 4-byte mask key added
In order: the 2-byte header, an extended length (only from 126 bytes up), the mask key (only from the browser), then the payload. Try café: 4 letters, 5 bytes, because é takes 2 bytes in UTF-8.

Ping, pong, and closing

Either side can send a ping, and the other must answer with a pong carrying the same payload. Servers commonly ping every 20 to 30 seconds to find dead clients and to keep proxies from closing an idle connection. Browsers answer pings automatically, but JavaScript can neither send a ping nor see one. That is why apps add their own heartbeat message, covered in the next chapter.

Closing is also a handshake. One side sends a close frame with a code and a reason of at most 123 bytes; the other answers with its own close frame; then the server closes the TCP connection. A connection that dies without this exchange, because a laptop slept or a cable was pulled, reports code 1006 in the browser.

Check your understanding · Exercise 2

A client sends the 2-character text message hi. Which are true? Select all that apply.

The browser API, and its sharp edges

The WebSocket API is small: a constructor, two methods, four events, and a few properties. It is easy to start with and easy to misuse, because it does almost nothing for you after the connection opens.

ts
const socket = new WebSocket("wss://chat.example.com/v1/socket", ["chat.v2", "chat.v1"]);
socket.binaryType = "arraybuffer";

socket.addEventListener("open", () => {
  console.log("speaking", socket.protocol); // "chat.v2"
  socket.send(JSON.stringify({ type: "hello", lastSeq: 1042 }));
});

socket.addEventListener("message", (event) => {
  const msg = JSON.parse(event.data as string);
  handle(msg);
});

socket.addEventListener("close", (event) => {
  // event.code, event.reason, event.wasClean
  scheduleReconnect(event.code);
});

socket.addEventListener("error", () => {
  // No details on purpose. A close event always follows.
});

readyState: four states, one direction

A socket moves through four states and never goes back. CONNECTING (0) while the handshake runs, OPEN (1) once the 101 arrives, CLOSING (2) after a close frame is sent, and CLOSED (3) at the end. A closed socket can't be reopened: reconnecting means creating a new WebSocket object and attaching your listeners again.

A state machine with four states. CONNECTING, readyState 0, goes to OPEN, readyState 1, when the 101 arrives, or straight to CLOSED, readyState 3, when the handshake fails. OPEN goes to CLOSING, readyState 2, when close is called or a close frame arrives, or straight to CLOSED with code 1006 when the network is lost. CLOSING goes to CLOSED once both close frames are exchanged. CLOSED is final: reconnecting needs a new WebSocket object.CONNECTINGreadyState 0OPENreadyState 1CLOSINGreadyState 2CLOSEDreadyState 3101close()handshake failsnetwork lost: 1006close frames donefinal: a reconnect needs a new WebSocket
Every arrow points forward. A socket in CLOSED never opens again, so a reconnect means a new WebSocket with its listeners attached again.

Two traps follow from this. Calling send while the state is CONNECTING throws an InvalidStateError, so code that sends right after new WebSocket() crashes. And calling send on a closed socket throws nothing: the data is silently dropped. Your app needs its own outbox that holds messages until the socket is open and the server has confirmed them.

bufferedAmount: the only backpressure you get

send never blocks and never fails on a slow network. It copies the data into a queue inside the browser and returns at once. socket.bufferedAmount tells you how many bytes are sitting in that queue, not yet handed to the network.

On a fast connection it stays near 0. On a phone in a lift, it grows with every send. If you stream cursor positions 60 times a second and never look at it, the queue grows without limit, memory climbs, and when the network recovers the server receives a burst of stale positions. Check it before sending anything that can be skipped: above a threshold, say 64 KB, drop or merge the update.

ts
const HIGH_WATER = 64 * 1024;

function sendCursor(socket: WebSocket, x: number, y: number) {
  if (socket.readyState !== WebSocket.OPEN) return;
  if (socket.bufferedAmount > HIGH_WATER) return; // stale soon anyway: skip
  socket.send(JSON.stringify({ type: "cursor", x, y }));
}

Close codes worth knowing

CodeMeaningWhat the client should do
1000Normal closure: the work is done.Nothing. Don't reconnect unless the app still needs the socket.
1001Going away: the page is closing, or the server is shutting down for a deploy.Reconnect with jitter, so all clients don't return in the same second.
1006Abnormal: no close frame arrived. Never sent on the wire; the browser reports it.The usual case after network loss or a failed handshake. Reconnect with backoff.
1008Policy violation: the server rejected something, such as the origin or the auth.Don't retry blindly. Refresh credentials first, or show an error.
1009Message too big for the receiver's limit.A bug in the sender. Split large data or send it over HTTP.
1011Internal error on the server.Reconnect with backoff, and report it.
4000 to 4999Yours to define, for example 4401 token expired.Whatever your protocol says.

From JavaScript, socket.close() accepts only 1000 or a code from 3000 to 4999; other codes throw. The reason string can be at most 123 bytes of UTF-8.

Heartbeats: noticing that the line is dead

Here is the problem that bites every real app. When Wi-Fi drops or a laptop sleeps, no close frame is sent. TCP only notices a dead peer when it tries to send and gets no acknowledgement, which can take minutes. Until then, readyState stays OPEN and your UI shows a green "connected" dot while nothing arrives.

Since the browser can't send protocol pings, the app sends its own: a tiny {"type":"ping"} every 25 seconds, and the server answers {"type":"pong"}. If no message of any kind arrives within about 2 heartbeat intervals, the client calls close() and opens a new socket at once, without waiting for the close event, which can be slow on a dead connection. The same traffic stops proxies from closing a quiet connection, as the proxies chapter explains.

Check your understanding · Exercise 3

Your chat app shows "connected" but messages stopped arriving after the user's laptop woke from sleep. What helps? Select all that apply.

Designing the message protocol

A WebSocket gives you a pipe for messages. What travels in it, and what each side promises about it, is your application protocol. HTTP gave you methods and status codes, and paired every response with its request for free. On a socket, a reply can arrive after 10 other messages, so the protocol has to carry that structure itself.

Three kinds of messages

Most real protocols, from chat apps to multiplayer games, end up with the same three kinds:

  • Requests with a reply. The client asks for something and needs to know the outcome: send a message, join a channel, load a page of history. Each carries a request id, and the server's ack or error repeats that id.
  • Events from the server. Things that happened: a new message, an edit, a member joined. Each carries a sequence number seq that increases by 1 in its stream, so the client can spot a gap and resume after a drop.
  • Signals. Typing, cursor positions, presence pings. They are worthless a few seconds late, so they get no id, no seq, and no retry. If one is lost, the next one replaces it.

The envelope

Wrap every message in the same outer shape, the envelope: a type field that says how to read the rest, plus the fields its kind needs. The client's router becomes a simple switch on type, and unknown types can be logged and ignored, which lets the server add new event types without breaking old clients.

json
// client to server: a request
{ "type": "message.send", "id": "r_41", "chat": "design",
  "clientMsgId": "m_9a1f", "text": "Lunch at 1?" }

// server to client: the reply to r_41
{ "type": "ack", "id": "r_41", "msgId": "88213", "seq": 1043 }

// server to client: an event in this user's stream
{ "type": "message.new", "seq": 1043, "chat": "design",
  "msg": { "id": "88213", "from": "maya", "text": "Lunch at 1?" } }

// either way: a signal, no id, no seq
{ "type": "typing", "chat": "design", "user": "leo" }

// server to client: a failed request
{ "type": "error", "id": "r_42", "code": "rate_limited", "retryAfterMs": 2000 }

Acks, retries, and never sending twice

Here is the hard case: Maya sends a message, and the connection dies. Did the server store it? If the ack arrived, yes. If not, she can't know: the message may have been lost on the way in, or stored with the ack lost on the way out.

The safe answer is to resend, and to make resending harmless. The client creates a clientMsgId once, when Maya presses Enter, and reuses it on every retry. The server remembers recent ids per user and, when it sees one again, returns the original ack instead of storing a second copy. This is idempotency, and it turns "at least once" delivery into "exactly once" as the user sees it.

A sequence diagram between the client and the server. The client sends message m_9a1f, try 1. The server stores it as message 88213 and sends an ack, but the ack is lost when the connection drops. The client reconnects and sends m_9a1f again, try 2. The server sees it already has m_9a1f, stores nothing new, and answers with the same ack, 88213. The user sees one message, none lost.ClientServersend m_9a1f (try 1)stored: 88213ack 88213 (lost)drop, then reconnectsend m_9a1f (try 2)seen: no copyack 88213 (same)one message on screen, nothing lost, nothing doubled
The client cannot tell a lost send from a lost ack, so it always retries. Reusing the same clientMsgId makes the retry safe: the server answers it from memory instead of storing a copy.

Sequence numbers: one counter per stream

Give every event a seq from one counter per stream. The stream can be per user (all events this user should see) or per channel. The client keeps lastSeq, the highest number it has applied. Three rules follow:

  1. If an event arrives with seq equal to lastSeq + 1, apply it and move lastSeq forward.
  2. If seq is less than or equal to lastSeq, it's a duplicate: drop it. This happens after a resume overlaps what you already had.
  3. If seq is larger than lastSeq + 1, you missed something. Ask the server for the events after lastSeq before applying this one.

Per-user streams make resume simple: one number covers everything. Per-channel streams scale better on the server but mean the client tracks a lastSeq per channel and resumes each one. The chat-app guide uses a sequence number per chat for ordering and an event log per user for resume, which combines both.

Decision How should messages be encoded?

JSON in text frames

JSON.stringify on send, JSON.parse on receive.

Optional permessage-deflate to compress repeated keys.

Pros

  • Readable in the dev tools Network panel.
  • No schema tooling; every language parses it.
  • Easy to add fields.

Cons

  • Field names repeat in every message.
  • Parsing large messages costs main-thread time.
  • No types unless you add a schema check.

Binary: Protocol Buffers or MessagePack

A schema (protobuf) or compact format (MessagePack) encodes to an ArrayBuffer.

Set binaryType = "arraybuffer" and decode on receive.

Pros

  • Smaller than JSON, often by a wide margin for numeric data.
  • Typed schemas shared by client and server.
  • Fast for numbers, game state, and audio.

Cons

  • Opaque in the dev tools.
  • A decoder library in the bundle.
  • Schema changes need care across versions.

What we pick: Start with JSON for chat, notifications, and collaboration: the messages are small and debuggability matters most. Move to a binary format when the profile shows encoding size or parse time matters, as in games, trading screens, and high-rate cursor streams.

Check your understanding · Exercise 4

Which design choices belong in a chat protocol over WebSockets? Select all that apply.

Reconnecting and resuming without losing anything

Connections drop all the time: a phone moves from Wi-Fi to 5G, a laptop sleeps, a server deploys, a load balancer recycles. For a chat app open all day, several drops per user per day is normal, and the WebSocket API never reconnects by itself. Reconnecting well has two halves: when to try again, and where to pick up.

When: exponential backoff with full jitter

Retrying at once in a tight loop is the classic mistake. If the server is down, every client hammers it hundreds of times a second, and the moment it comes back, the flood knocks it over again. Exponential backoff doubles the wait after each failure: 0.5 s, 1 s, 2 s, 4 s, up to a cap such as 30 s.

Backoff alone is not enough. Picture a deploy that drops 1 million connections at the same instant. With plain backoff, all of them retry at exactly 0.5 s, then the failures retry at exactly 1 s later, and so on: a series of synchronized spikes, a thundering herd.

Jitter fixes it by picking a random delay. With full jitter, the wait is a random value between 0 and the backoff ceiling, which spreads the herd evenly across the window.

ts
const BASE_MS = 500;
const CAP_MS = 30_000;
let attempt = 0;
let stableTimer: ReturnType<typeof setTimeout> | undefined;

function nextDelay(): number {
  const ceiling = Math.min(CAP_MS, BASE_MS * 2 ** attempt);
  attempt += 1;
  return Math.random() * ceiling; // full jitter: anywhere in [0, ceiling)
}

function onOpen() {
  // Reset only after the connection has proved stable, or a server
  // that accepts and then crashes causes a fast reconnect loop.
  stableTimer = setTimeout(() => (attempt = 0), 30_000);
}

function onClose(event: CloseEvent) {
  clearTimeout(stableTimer);
  if (event.code === 1000) return; // closed on purpose
  if (!navigator.onLine) {
    window.addEventListener("online", connect, { once: true });
    return;
  }
  setTimeout(connect, nextDelay());
}

Try it in the lab below, a simplified model rather than a measurement. A deploy drops every client at once, and the servers can accept 10 percent of them per second. Without jitter, the refused clients all retry in the same instant again, so each wave lets only a few through: after a full minute, only about 15 percent are back. With full jitter, the same clients spread out, and everyone is back in about 35 seconds.

Clients dropped
Backoff
Reconnect attempts per second for 10,000 clients dropped at once, without jitter. The servers accept 1,000 handshakes per second. Peak: 10,000 attempts in one second. Back after 10 seconds: 10 percent; after 30 seconds: 13 percent. Failed handshakes: 54,750. Not everyone is back within 60 seconds.dashed line: capacity 1,000/speak 10,000/s0 s40 sback at 10 s: 10%, at 30 s: 13%, not all back by 60 s
Each bar is one second: the solid part got through, the hatched part was refused and backs off. Without jitter, the refused clients retry in the same instant again, wave after wave.

Where: resume from the last sequence number

A new connection is a blank page: the server doesn't know what this client already has. If the client simply reconnects, every event sent during the 8 seconds it was gone is lost. This is where the seq from the protocol chapter pays off. The first message on every new socket is a hello that carries lastSeq:

json
{ "type": "hello", "lastSeq": 1043, "subscriptions": ["design", "general"] }

The server keeps a short replay buffer of recent events per stream, for example the last 5 minutes or the last 1,000 events. If lastSeq is still inside it, the server sends events 1044 onward, then switches to live. If the client was gone too long, the server answers resync, and the client reloads current state over HTTP, takes the new seq from that response, and continues. Both paths end in the same place; one is just cheaper.

  1. Open the socket with a fresh auth ticket.
  2. Send hello with lastSeq and the channels to subscribe to again.
  3. Apply replayed events, dropping any seq already applied.
  4. Resend everything in the outbox that never got an ack, with the same clientMsgId.
  5. Mark the UI as connected only now, not on open.

In the second lab, drop the connection between events, reconnect with or without resume, and watch which events the client ends up with.

On reconnect
The server has sent events 1 to 0 and keeps the last 4 in its replay buffer. The client has applied 0 of them. Last exchange: Connected. Send a few events, then drop the connection. Nothing sent yet.server stream (seq)hatched: still in the replay buffer (last 4)client applied (lastSeq 0)Connected. Send a few events, then drop the connection.Nothing sent yet.
The server keeps only the last 4 events. Miss up to 4 and a resume replays them; miss more and the server answers resync, so the client reloads state over HTTP. Without lastSeq, every event sent while offline is lost.

Check your understanding · Exercise 5

A deploy drops 500,000 sockets at once. Which client behaviours keep the servers standing? Select all that apply.

Scaling: from one server to many

With HTTP, scaling is mostly adding servers behind a load balancer, since any server can answer any request. WebSockets change the unit of work. The question is no longer "how many requests per second" but "how many open connections, and how does a message reach all the right ones".

What one open connection costs

An idle WebSocket uses almost no CPU, but it holds memory: kernel socket buffers, TLS state, the server's per-connection objects, and any compression context (permessage-deflate that keeps its context between messages can cost tens to hundreds of KB per connection). A careful setup without that runs at a few KB to a few tens of KB per connection. It also holds a file descriptor, so operating system limits must be raised.

With that tuning, one machine can hold a very large number of mostly idle connections. The Phoenix framework team famously reached 2 million on a single large server in 2015. Real products run far fewer per machine, often 10,000 to 100,000, because the hard part is not holding connections but the work they create: fan-out, heartbeats, and reconnect storms.

Load balancers and stickiness

The load balancer chooses a server once, at the handshake. After that the connection is pinned to that server until it closes, so a WebSocket is sticky by nature. Two consequences:

  • Uneven load after scaling out. New servers start empty while old ones stay full, because existing connections never move. Rebalance by asking a slice of clients on busy servers to reconnect (close with 1001), a few at a time.
  • Sticky sessions for fallbacks. Libraries such as Socket.IO can start on HTTP long polling and upgrade later. Those multiple HTTP requests must reach the same server, so they need cookie or IP based sticky sessions. Pure WebSockets don't.

Fan-out: reaching users on other servers

Maya is connected to gateway A and Leo to gateway C. When Maya posts in channel design, gateway A must get the message to Leo's socket on gateway C. With one server, this is a loop over a local list. With 50 servers, you need a way to route between them.

An architecture diagram. Three clients, Maya, Ana and Leo, each hold a socket to a different gateway: A, B and C. Maya posts in channel design through gateway A, which publishes it to a pub/sub broker. The broker sends it only to gateway C, which has a subscriber in design, and gateway C pushes it to Leo. Gateway B has no one in design and is not subscribed. The message is also saved with its sequence number in the message store.gatewaysMayaGateway AAnaGateway BLeoGateway CPub/subtopic: designpublishfan outMessagesseq 1045one publish reaches every subscribed gateway
Each gateway only knows its own sockets. The broker connects them: one publish from gateway A reaches every gateway that subscribed to the channel, and only those.

Decision How does a message reach every socket that should see it?

Pub/sub broker

Each gateway subscribes to a topic per channel that its connected users care about.

A new message is published once to the broker (Redis pub/sub, NATS, Kafka); every subscribed gateway sends it to its local sockets.

Pros

  • Any user can connect to any gateway.
  • Gateways stay simple and easy to add.
  • The usual design for chat and notifications.

Cons

  • Another system to run, and its throughput limits fan-out.
  • Delivery guarantees depend on the broker; plain Redis pub/sub drops messages for disconnected subscribers, so durable history lives elsewhere.

Route by room

The load balancer or a lookup service sends every user of one room (a document, a game match) to the same server, for example with consistent hashing on the room id.

Pros

  • All users of a room share memory: no broker hop, lowest latency.
  • Natural for collaborative documents and game matches with authoritative state in one place.

Cons

  • A huge room can overload one server.
  • Users in many rooms need many connections, or a proxy layer.
  • Moving a room means reconnecting its users.

Managed service

A hosted real-time service (for example Ably, Pusher, or a cloud provider's WebSocket API) holds the connections; your backend publishes over HTTP.

Pros

  • No connection servers to run or deploy.
  • Global edge presence and fan-out included.

Cons

  • Cost per message or connection at scale.
  • Less control over the protocol, resume, and limits.

What we pick: Chat, notifications, and feeds: gateways plus a pub/sub broker, with durable storage for history and resume. Collaborative editing and games: route by room, so one server owns each room's state. Small teams or spiky traffic: a managed service until the bill says otherwise.

Keep the gateways thin

A pattern that shows up at Slack, Discord, and most large chat systems is a gateway layer that does only connection work: the handshake, auth, heartbeats, subscriptions, and fan-out. Business logic lives in ordinary services behind it. Gateways then change rarely, so deploys that drop connections are rare too, while the services behind them deploy many times a day without anyone noticing.

When a gateway must restart, drain it gently: stop accepting new connections, then close existing ones with 1001 in batches over a few minutes, not all at once. Combined with client jitter and resume, users see at most a short "Reconnecting" banner.

Limits on the client side

Each tab that runs your app opens its own socket. A user with 6 tabs open costs 6 connections, 6 sets of heartbeats, and 6 copies of every event. Browsers also cap the total number of WebSockets per profile. The fix is to keep one socket per browser and share it across tabs, through a SharedWorker or a leader tab elected with BroadcastChannel; the cross-tab sync lesson covers both.

Check your understanding · Exercise 6

Your chat backend grows from 1 to 20 gateway servers. Which statements are true? Select all that apply.

Proxies, load balancers, and security

Between the browser and your gateway sit several machines that were built for short HTTP requests: corporate proxies, CDNs, load balancers, and reverse proxies such as nginx. Each must be told that a connection may upgrade and then stay open for hours. Many WebSocket outages in production come from one of them.

Reverse proxies and idle timeouts

Upgrade and Connection are hop-by-hop headers: each proxy uses them for its own connection and, by default, does not forward them. A reverse proxy must pass them on explicitly, or the server sees a plain GET and answers 200 or 400, and the browser reports 1006. In nginx:

text
location /v1/socket {
  proxy_pass http://gateways;
  proxy_http_version 1.1;
  proxy_set_header Upgrade $http_upgrade;
  proxy_set_header Connection "upgrade";
  proxy_read_timeout 75s;   # default is 60s: longer than the heartbeat gap
}

The second trap is the idle timeout. nginx closes a proxied connection after 60 seconds without data by default, and many cloud load balancers also default to around 60 seconds. A chat channel can easily stay quiet for a minute. Your heartbeat interval must be shorter than the smallest idle timeout on the path, which is why 20 to 30 seconds is the common choice.

Old corporate proxies that inspect plain HTTP sometimes break ws:// connections. With wss://, they see only an encrypted tunnel and have nothing to rewrite. Socket.IO keeps a long polling fallback for the rare network where even that fails.

WebSockets over HTTP/2 and HTTP/3

The classic handshake needs HTTP/1.1, so each WebSocket needs its own TCP and TLS connection, even when the page's other requests share one HTTP/2 connection. RFC 8441 (2018) defines an extended CONNECT method that opens a WebSocket as one stream inside an existing HTTP/2 connection, and RFC 9220 does the same for HTTP/3.

Chromium browsers and Firefox use RFC 8441 when the server announces support, but many load balancers still speak the HTTP/1.1 upgrade to the backend anyway. For frontend design this is a performance detail: your code and protocol stay the same, and servers must keep the HTTP/1.1 path working.

Origin checks: the WebSocket CSRF

WebSockets are not covered by CORS. Any page on any site can run new WebSocket("wss://chat.example.com/v1/socket"), and the browser attaches your user's cookies for that host, subject to their SameSite settings. If the server authenticates by cookie alone, an attacker's page can open a socket as your user and read their messages. This is cross-site WebSocket hijacking.

The defence is simple and mandatory: the server checks the Origin header against an allow list during the handshake and rejects anything else. A script outside a browser can fake Origin, but it doesn't have the user's cookies, so the check holds where it matters.

Authenticating the connection

The browser's WebSocket constructor has no way to set headers, so an Authorization header is not an option. Three patterns are used instead:

PatternHowWatch out for
CookieThe session cookie goes with the upgrade request, as for any request to that host.Requires a strict Origin check. Works only when the socket host shares the site with the page.
Ticket in the URLPOST /realtime/ticket with normal auth returns a random token valid once, for 30 s. Open wss://.../socket?ticket=....URLs land in logs, so never put a long-lived token there. Single use and short expiry make a logged ticket worthless.
First messageOpen the socket, then send { "type": "auth", "token": "..." } first.The server holds an unauthenticated socket briefly; close it if no valid auth arrives within a few seconds.

The chat-app guide uses the ticket pattern, and it is a good default for a socket on its own subdomain. Avoid hiding a token in Sec-WebSocket-Protocol: it works, but it misuses the subprotocol header and the token ends up in more logs.

After the connection is open

  • Authorize every message, not only the handshake. Being connected doesn't mean Leo may post in a channel he was removed from an hour ago.
  • Expire sessions. A socket can outlive its token. Close with an app code such as 4401 when it expires, or accept a re-auth message.
  • Limit sizes. Set a maximum message size (say 64 KB for chat) and close with 1009 when exceeded. Send files over HTTP.
  • Rate-limit per connection and per user. A token bucket of, say, 10 messages per second with small bursts. Reply with an error first; close with 1008 on abuse.
  • Limit connections per user and per IP, and rate-limit handshakes, which are the expensive part (TLS plus auth).
  • Validate and escape message contents like any other input. Text from a socket rendered as HTML is a cross-site scripting hole, just like text from an API.

Check your understanding · Exercise 7

Your socket server authenticates with the session cookie. What else must it do? Select all that apply.

What comes next, and when you don't need WebSockets

The WebSocket API is more than 15 years old, and it shows: events instead of promises, no backpressure on the receiving side, and a single ordered stream where one large message delays every small one behind it. Two newer APIs address this, though neither replaces WebSockets for most apps yet.

WebSocketStream: the same protocol, with streams

WebSocketStream speaks the exact same protocol, so servers need no changes. Instead of events, it gives you a ReadableStream and a WritableStream. The gain is backpressure in both directions: if your code reads slowly, the browser stops reading from the network, TCP's flow control slows the server down, and memory stays flat. With the classic API, messages pile up in your message handler however slow you are.

ts
const wss = new WebSocketStream("wss://feed.example.com/ticks");
const { readable, writable } = await wss.opened;

const writer = writable.getWriter();
await writer.write(JSON.stringify({ type: "subscribe", symbol: "ACME" }));

for await (const data of readable) {
  await render(data); // the next message isn't pulled until this resolves
}

It ships only in Chromium browsers (Chrome and Edge 124 and later) and is still marked experimental; Firefox and Safari don't support it. Treat it as an enhancement with a fallback to WebSocket.

WebTransport: many streams and datagrams over HTTP/3

WebTransport runs over HTTP/3, which runs over QUIC on UDP. One session can open many independent streams, so a large file transfer doesn't delay small chat messages, and it can send datagrams: unreliable, unordered packets for data that is useless when late, such as game positions or live media. It also starts faster on a new connection.

Chrome, Edge, and Firefox have supported it for some time, and Safari added it in version 26.4 in 2026, so browser support is finally broad. The costs are on the server side: you need an HTTP/3 server that supports WebTransport, networks that allow UDP (some corporate ones block it), and a WebSocket fallback anyway. For games, live video, and high-rate collaboration it is worth evaluating; for chat it adds work without a clear gain today.

When SSE or polling is enough

WebSockets bring the most design work of all the real-time options. Use the decision below before you reach for them:

A decision tree. First question: does the client send often? If yes, use a WebSocket, and if you also need datagrams, where late data is useless, consider WebTransport; otherwise stay on WebSocket. If the client does not send often, ask whether pushes must arrive within seconds. If yes, use SSE plus fetch. If no, use short polling.Client sends often?WebSockettwo-way, frequentyesnoPush within seconds?SSE + fetchserver to clientyesnoShort pollingslow updatesNeed datagrams?yesWebTransportcheck server supportif no: stay on WebSocket
The first question is about the client, not the server. Most features where only the server pushes end on the right side of this tree, with less work than a WebSocket.
  • Server to client only, or rare client sends: a notification badge, a build log, a streamed AI answer, live scores. Use SSE and send the occasional action with fetch. You get reconnect and Last-Event-ID resume for free.
  • Updates every 30 seconds or more, or an audience of millions where a few seconds of delay is fine: short polling with good HTTP caching is simplest and scales on a CDN.
  • Frequent messages both ways, low latency: chat, multiplayer, collaborative editing, live cursors. WebSockets.

The polling and SSE lessons cover those options in depth, and the choosing-realtime lesson compares them side by side.

In real products

  • Discord's gateway is a public, documented WebSocket protocol. The server's first message sets the heartbeat interval, and after a drop the client resumes with its session id and the last sequence number it saw: the pattern from this lesson.
  • Slack gives every client a persistent WebSocket to gateway servers, which hold the connections and channel subscriptions and receive messages from separate channel servers.
  • Collaborative editors such as Figma keep one WebSocket per open document and send small operations both ways; the collaboration lessons cover what travels in those messages.

Using it in a system design interview

Bring WebSockets up when the requirements include frequent messages in both directions: chat, presence, typing, multiplayer, collaboration. Say why in one sentence, naming the alternative you rejected: "SSE would cover incoming messages, but typing and receipts make the client send several times a second, so a WebSocket is cheaper."

  • Draw: the client with an outbox and a lastSeq, one socket to a gateway layer, a pub/sub broker, and the services and storage behind it.
  • Name the protocol: typed envelopes, request ids with acks, clientMsgId for idempotency, a seq per stream, and signals without either.
  • Cover the drops: heartbeat every 25 s, backoff with full jitter, resume from lastSeq, resync over HTTP when the gap is too old.
  • Mention the edges: auth by ticket with an Origin check, bufferedAmount for backpressure, one socket shared across tabs.

Check your understanding · Exercise 8

Which features need a WebSocket rather than SSE or polling? Select all that apply.

Summary

  • A WebSocket is one TCP connection, upgraded by an HTTP GET that gets 101 Switching Protocols, after which either side sends whole messages with 2 to 14 bytes of framing.
  • Sec-WebSocket-Accept is base64 of SHA-1 of the key plus a fixed GUID: proof the server understood the request, not security. Security is wss://, an Origin allow list, auth on connect, and checks on every message.
  • Client frames are masked to protect proxies; browsers answer protocol pings but can't send them, so apps add a heartbeat every 20 to 30 seconds.
  • The browser gives you almost nothing after open: design typed envelopes, request ids with acks, a clientMsgId for idempotent retries, and a seq per stream.
  • Reconnect with exponential backoff and full jitter, and resume by sending lastSeq; fall back to a full resync over HTTP when the gap is too old.
  • Watch bufferedAmount and drop stale signals when it grows; it is the only backpressure the classic API gives you.
  • Scaling is about connections and fan-out: thin gateways, a pub/sub broker or room routing, gentle drains on deploy, and one socket shared across tabs.
  • If the client rarely sends, SSE or polling is simpler. WebSocketStream and WebTransport are the next step for backpressure, many streams, and datagrams.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

Walk me through what happens when the browser runs new WebSocket("wss://...").

Question 2

How do you make sure a chat message is neither lost nor shown twice when the connection drops mid-send?

Question 3

A deploy drops a million WebSocket connections. What happens next, and how do you design for it?

Question 4

How do you authenticate a WebSocket from the browser, given that you can't set headers?

Question 5

When would you not use WebSockets?

Practice it

These design problems lean on websockets.

References

Read next