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.
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.
WebSocketnever reconnects on its own, unlikeEventSource. - 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:
- 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.
- TLS encrypts that stream. A
wss://URL means TLS, just likehttps://. Aws://URL means no encryption. - 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.
- 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.
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:
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...
Upgrade: websocketandConnection: Upgradeask to switch this connection to another protocol.Sec-WebSocket-Version: 13is the only version in use today.Sec-WebSocket-Keyis 16 random bytes, base64-encoded, new for every connection. Here we used the 16 readable bytescompletefrontendso you can check the math.Sec-WebSocket-Protocollists the subprotocols the client can speak, in order of preference. This is how you version your application protocol.Sec-WebSocket-Extensionsoffers extensions.permessage-deflatecompresses each message.Originis 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/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: pj5YwqTkNHwQ6kddkNoHomx7jlM=
Sec-WebSocket-Protocol: chat.v2
Sec-WebSocket-Extensions: permessage-deflate
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.
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="
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.
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
FINset. - RSV1 to RSV3 (3 bits): reserved for extensions.
permessage-deflatesetsRSV1on 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.
| Opcode | Name | What it carries |
|---|---|---|
0x0 | Continuation | The next piece of a message that was split into several frames. |
0x1 | Text | A message that must be valid UTF-8. The browser gives you a string. |
0x2 | Binary | Raw bytes. The browser gives you a Blob or an ArrayBuffer. |
0x8 | Close | Starts or answers the closing handshake: a 2-byte close code and an optional reason. |
0x9 | Ping | "Are you there?" The other side must answer with a pong. |
0xA | Pong | The 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.
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.
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.
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.
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
| Code | Meaning | What the client should do |
|---|---|---|
1000 | Normal closure: the work is done. | Nothing. Don't reconnect unless the app still needs the socket. |
1001 | Going 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. |
1006 | Abnormal: 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. |
1008 | Policy violation: the server rejected something, such as the origin or the auth. | Don't retry blindly. Refresh credentials first, or show an error. |
1009 | Message too big for the receiver's limit. | A bug in the sender. Split large data or send it over HTTP. |
1011 | Internal error on the server. | Reconnect with backoff, and report it. |
4000 to 4999 | Yours 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'sackorerrorrepeats thatid. - Events from the server. Things that happened: a new message, an edit, a member joined. Each carries a sequence number
seqthat 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, noseq, 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.
// 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.
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:
- If an event arrives with
seqequal tolastSeq + 1, apply it and movelastSeqforward. - If
seqis less than or equal tolastSeq, it's a duplicate: drop it. This happens after a resume overlaps what you already had. - If
seqis larger thanlastSeq + 1, you missed something. Ask the server for the events afterlastSeqbefore 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?
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.
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.
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:
{ "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.
- Open the socket with a fresh auth ticket.
- Send
hellowithlastSeqand the channels to subscribe to again. - Apply replayed events, dropping any
seqalready applied. - Resend everything in the outbox that never got an
ack, with the sameclientMsgId. - 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.
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.
Decision How does a message reach every socket that should see it?
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:
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:
| Pattern | How | Watch out for |
|---|---|---|
| Cookie | The 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 URL | POST /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 message | Open 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
4401when it expires, or accept a re-auth message. - Limit sizes. Set a maximum message size (say 64 KB for chat) and close with
1009when 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
errorfirst; close with1008on 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.
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:
- 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 andLast-Event-IDresume 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,
clientMsgIdfor idempotency, aseqper 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
Origincheck,bufferedAmountfor 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
GETthat gets101 Switching Protocols, after which either side sends whole messages with 2 to 14 bytes of framing. Sec-WebSocket-Acceptis base64 of SHA-1 of the key plus a fixed GUID: proof the server understood the request, not security. Security iswss://, anOriginallow 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, aclientMsgIdfor idempotent retries, and aseqper 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
bufferedAmountand 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.
WebSocketStreamand 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://...").
The browser opens a TCP connection, does the TLS handshake, and sends an HTTP/1.1 GET with Upgrade: websocket, Connection: Upgrade, Sec-WebSocket-Version: 13, a random 16-byte Sec-WebSocket-Key, the Origin, cookies, and any subprotocols I passed.
The server checks the origin and auth, then answers 101 Switching Protocols with Sec-WebSocket-Accept, which is base64 of SHA-1 of the key plus a fixed GUID. The browser verifies it, the socket becomes OPEN, and from then on both sides exchange frames: text, binary, ping, pong, close. Client frames are masked. If the handshake fails, the page only sees 1006.
Question 2
How do you make sure a chat message is neither lost nor shown twice when the connection drops mid-send?
The client puts the message in a persistent outbox with a clientMsgId created once, and shows it as pending. It sends it with a request id and waits for an ack.
If the socket drops before the ack, it reconnects and resends with the same clientMsgId. The server deduplicates on that id and returns the original ack, so the retry is harmless. Only the ack removes the message from the outbox. Incoming messages carry a seq, so duplicates are dropped and gaps are detected.
Question 3
A deploy drops a million WebSocket connections. What happens next, and how do you design for it?
Without care, a million clients reconnect in the same second, every handshake does TLS and auth, and then every client reloads its full state over HTTP. That can knock over the gateways and the API behind them.
On the client: exponential backoff with full jitter to spread reconnects over seconds, and resume from lastSeq so the server replays only missed events. On the server: thin gateways that rarely deploy, drains that close connections with 1001 in batches, a replay buffer per stream, and a resync answer when the gap is too old.
Question 4
How do you authenticate a WebSocket from the browser, given that you can't set headers?
Three options: the session cookie with a strict Origin allow list, a short-lived single-use ticket fetched over normal HTTP and passed in the URL, or a token in the first message with a short deadline. I'd default to the ticket for a socket on its own host, since URLs get logged and a 30-second single-use ticket is worthless in a log.
Whatever the choice, the server also checks Origin against cross-site WebSocket hijacking, authorizes each action, and closes the socket with an app code like 4401 when the session expires.
Question 5
When would you not use WebSockets?
When data flows mostly from the server: notifications, feeds, build logs, streamed AI answers. SSE gives push, automatic reconnect, and resume with Last-Event-ID over plain HTTP, and actions go over fetch. When updates are slow or the audience is huge and tolerant of delay, polling a cached endpoint is cheapest.
WebSockets earn their cost when the client also sends often, like chat with typing, multiplayer, and collaboration. For unreliable, high-rate data such as game state or media, WebTransport's datagrams are now worth a look.
Practice it
These design problems lean on websockets.
- Design a notification centerMedium
- Design state sync across browser tabsMedium
- Design a real-time leaderboardMedium
- Design a live stock price tickerMedium
- Design Stack OverflowMedium
- Design a food delivery appMedium
- Design an online chess gameMedium
- Design an e-signature workflow like DocuSignMedium
- Design a Kanban boardHard
- Design live comments for a sports streamHard
- Design a chat appHard
- Design a multiplayer trivia gameHard
- Design DiscordHard
- Design the Spotify web playerHard
- Design a seat selection map for eventsHard
- Design Uber's rider web appHard
- Design a collaborative document editor like Google DocsHard
- Design a whiteboard like ExcalidrawHard
- Design Trello or JiraHard
- Design SlackHard
- Design an online judge like LeetCodeHard
- Design a code review diff viewerHard
- Design a web-based terminalHard
- Design an IoT dashboard for thousands of sensorsHard
- Design a customer support ticket dashboardHard
- Design a collaborative pixel canvas like r/placeHard
- Design a video call web app like Google MeetHard
- Design a Twitch-style live stream pageHard
- Design a stock trading appHard
References
- RFC 6455: The WebSocket Protocol The handshake, framing, masking, and close codes.
- MDN: The WebSocket API The browser API, with guides for clients and servers.
- MDN: WebSocketStream The promise and stream based API, with browser support.
- RFC 8441: Bootstrapping WebSockets with HTTP/2 Extended CONNECT for WebSockets inside an HTTP/2 connection.
- MDN: WebTransport API Streams and datagrams over HTTP/3.
- Exponential Backoff And Jitter (AWS Architecture Blog) Why full jitter spreads a retry herd best.
- Discord Developer Docs: Gateway A real, public WebSocket protocol with heartbeats and resume.
- WHATWG WebSockets Standard How browsers run the handshake, including the subprotocol check.
- Slack Engineering: Real-time Messaging Gateway servers, channel servers, and fan-out at Slack.
- The Road to 2 Million Websocket Connections in Phoenix The 2015 single-server benchmark, and the tuning it needed.
Read next
- Server-sent eventsOne HTTP response that never finishes: the server keeps writing small text events to it, and the browser's
EventSourceparses them, reconnects on its own, and resumes where it left off. The simplest push channel when data flows from server to client. - Long pollingThe browser asks for updates and the server holds the request open until it has something to say: near-instant updates using nothing but ordinary HTTP, at the price of one parked request per user.
- Choosing a real-time techniqueHow to pick between short polling, long polling, server-sent events, WebSockets, and WebRTC for each feature, starting from who talks, how often, and how fresh the data must be, then checking scale, proxies, battery, and the fallback you will ship.
- Retries, timeouts, and backoffHow a client survives a flaky network and a struggling server: give every request a deadline, retry only the failures that can heal, space the retries out with exponential backoff and jitter, and stop retrying when the server or the network is clearly down.
- Real-time collaborationHow several people edit one document at the same time: each person sees their own typing instantly, a server puts everyone's changes in one order, and every copy ends up the same.
- HTTP/1.1, HTTP/2, and HTTP/3The same requests carried three ways (one at a time per TCP connection, many at once on one TCP connection, or many at once on QUIC over UDP), which decides bundling, sprites, sharding, and how a lossy network feels.