Core Concepts
WebSockets
One persistent connection. Both sides can talk anytime. The foundation of real-time bidirectional communication.
What Are WebSockets?
A persistent two-way message channel, and nothing more than that
WebSockets (RFC 6455) turn one HTTP exchange into a persistent, full-duplex channel of discrete messages between a client and a server. After the handshake either side can send at any moment without waiting for the other to ask, so there is no request/response pairing and no per-message HTTP overhead. That suits chat, collaborative editing, multiplayer coordination and live trading screens, where data flows both ways and latency is felt by the user.
A WebSocket is a transport for application messages, not a messaging system. It gives you ordered delivery of messages on a single live connection. It does not give you durable storage, offline delivery, acknowledgments, replay, fan-out across servers, authorization or exactly-once processing. Each of those is an application decision layered on top, and most of this lesson is about making them.
A useful mental model has three layers. The connection is TCP plus TLS plus the handshake; it is stateful, it lives on exactly one gateway process, and it dies when the network path, the process or a proxy decides it should. The frames are what the protocol puts on that connection: text, binary, ping, pong, close. The application protocol is the JSON or binary message vocabulary you define: message types, ids, acknowledgments, cursors. Reliability lives in the third layer. The first two are a pipe.
Take a chat room with 20 users. Alice sends a message over her socket. The server authenticates and authorizes it, persists it if the product promises history, assigns a message id, and pushes it to the members who are online. If Bob's connection dropped a moment earlier, the frame is not retained for him anywhere inside WebSocket. He needs a catch-up API or a durable event stream when he reconnects, and the design of that recovery path is as much of the system as the live path.
Compare this with the neighbouring techniques so the trade is clear. Polling and long polling stay inside ordinary request/response and keep servers stateless, at the price of latency or reconnect churn. Server-Sent Events keep one long response open for one-way server push and bring reconnection and a resume header with them. WebSockets pay for two-way messaging with a stateful connection you must authenticate, heartbeat, drain, scale and recover yourself.
Visual Story
Alice and Bob exchange messages over persistent sockets, with typing indicators flowing the other way
WebSockets
⤢ Expand for synchronized narration timeline
How Does a WebSocket Connection Start?
The HTTP Upgrade handshake, what it proves and what it does not
For a classic WebSocket over HTTP/1.1 the browser sends an Upgrade request, and the server returns 101 Switching Protocols after validating it:
GET /chat/socket HTTP/1.1
Host: chat.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: <base64-nonce>
Sec-WebSocket-Version: 13
Origin: https://chat.example.com
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: <computed-value>Upgrade and Connection ask to switch protocols on this TCP connection. Sec-WebSocket-Key is 16 random bytes, Base64-encoded, generated fresh by the browser for every attempt. Sec-WebSocket-Version: 13 is the only version in use. Origin is added by the browser and tells the server which page started the request; it is the basis of the cross-site defense covered below. The request may also carry Sec-WebSocket-Protocol, which lists application subprotocols the client can speak (the server picks one), and Sec-WebSocket-Extensions, most commonly permessage-deflate for compression. Compression trades CPU and per-connection memory for bandwidth, so measure it before enabling it for hundreds of thousands of sockets.
The server's accept value is derived from the client's key: append the protocol-defined GUID, take the SHA-1 digest, Base64-encode it.
// Node.js. The key and the expected result are the worked example from RFC 6455.
import { createHash } from 'node:crypto';
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
function acceptValue(secWebSocketKey) {
return createHash('sha1').update(secWebSocketKey + WS_GUID).digest('base64');
}
console.log(acceptValue('dGhlIHNhbXBsZSBub25jZQ==')); // s3pPLMBiTxaQ9kYGzzhZRbK+xOo=This proves the endpoint understands the WebSocket handshake and that the response is not a cached or confused HTTP reply. It is not user authentication. Anyone can compute it. The server must separately decide who the client is and what it may do, which is why the handshake is also the place for the Origin and credential checks in the authentication section below.
WebSockets can also be established over HTTP/2 using extended CONNECT (RFC 8441), and over HTTP/3 with the analogous mechanism (RFC 9220), where supported. In that case there is no 101 at all: the browser opens a stream with :method CONNECT and :protocol websocket, the server answers 200, and WebSocket frames travel inside DATA frames on that stream, multiplexed with ordinary requests on the same connection. The server must advertise support through the SETTINGS_ENABLE_CONNECT_PROTOCOL setting. So when you debug or teach a browser deployment, establish which path the real gateway and client stack use, rather than assuming every WebSocket is an HTTP/1.1 Upgrade. Which path the browser takes depends on the browser version, whether the gateway advertises the setting, and what the load balancer negotiates.
What gets between the browser and your code
Every hop in front of the gateway has to understand Upgrade. A reverse proxy must forward the Upgrade and Connection headers, which are hop-by-hop and so are dropped unless the proxy sets them explicitly. It must also use an idle timeout longer than your heartbeat interval; many proxies and load balancers default to around 60 seconds of silence (nginx proxy_read_timeout and the AWS ALB idle timeout are two examples), which silently kills a quiet socket unless something is sent more often than that.
# nginx in front of the gateways. The two headers are hop-by-hop, so the proxy must set them itself.
upstream chat_gateways { server 10.0.1.11:8080; server 10.0.1.12:8080; }
server {
listen 443 ssl;
server_name chat.example.com;
location /chat/socket {
proxy_pass http://chat_gateways;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 120s; # must exceed the interval between frames, so it must exceed your ping interval
}
}A browser reports a failed handshake in a deliberately uninformative way. If the server answers 401, 403 or 502 instead of 101, the page sees an error event and then a close event with code 1006, the same thing it sees when the network is down. JavaScript cannot read the HTTP status. Design around that: do anything that needs a meaningful error (login expired, quota exceeded, wrong version) over an ordinary HTTP endpoint first, or accept the upgrade and send an application-level error message plus a specific close code immediately afterwards.
What Travels Over the Connection?
Frames, control messages, the close handshake and what the browser API exposes
Once established, messages travel as WebSocket frames over the underlying connection. A frame starts with two bytes: the first holds a FIN bit (this is the last frame of the message), three reserved bits used by extensions, and a 4-bit opcode; the second holds a MASK bit and a 7-bit payload length. A length of 126 means the true length follows in 2 bytes, 127 means it follows in 8 bytes. If the MASK bit is set a 4-byte masking key follows, and then the payload.
- Header bytes
- 2
- Payload bytes
- 5
- Frame total
- 7 B
- Header share
- 28.6 %
0x81 is FIN=1 with opcode 1 (text). 0x05 is MASK=0 and a 7-bit length of 5. Server-to-client frames are never masked.
The picker shows why WebSocket framing is cheap: the header is between 2 and 14 bytes, so for a typical chat message it is a small fraction of the frame, and compared with an HTTP request carrying hundreds of bytes of headers, repeated every time, the steady-state overhead is far lower. What WebSocket does not shrink is the cost of TCP, TLS and your own JSON.
- Data frames. Opcode 1 is text (payload must be valid UTF-8), opcode 2 is binary, and opcode 0 is a continuation. Fragmentation lets one message span several frames: the first frame carries opcode 1 or 2 with FIN=0, continuation frames follow, and the last has FIN=1. The receiving implementation reassembles them before handing a message to your code, so application code sees whole messages, not fragments.
- Control frames. Close (opcode 8), ping (9) and pong (10). They carry at most 125 bytes, are never fragmented, and may be interleaved between the fragments of a data message, which is what lets a heartbeat get through while a large message is still being sent.
- Masking. Every frame from the client to the server is masked with a fresh random 32-bit key; server-to-client frames are not. This is not encryption. It exists so that script-controlled bytes cannot be shaped to look like an HTTP request to a confused intermediary. Confidentiality comes from
wss://, which is TLS.
Closing: status codes and the close handshake
Closing is a two-step exchange: one side sends a close frame with a 2-byte status code and an optional UTF-8 reason, the other replies with its own close frame, then the TCP connection is shut down. The code tells the other side whether to come back, which makes it the cheapest piece of reconnect policy you have.
1000normal closure;1001going away (the page navigated away, or the server is shutting down).1006abnormal closure. It is never sent on the wire; the browser reports it locally when the connection ended without a close frame, which includes failed handshakes and dropped networks.1008policy violation;1009message too big;1011unexpected server error.1012service restart and1013try again later, registered with IANA rather than defined in RFC 6455, and a natural fit for deploys and overload.4000to4999are free for application use. Defining one for "session ended, do not reconnect" (this lesson uses 4401) is the usual way to stop a reconnect loop.
What the browser API does and does not expose
The browser's WebSocket API is small. It gives you send() (string, Blob or ArrayBuffer), onopen, onmessage, onerror, onclose, readyState, binaryType and bufferedAmount. Several limits matter for design, and they are limits of the browser API rather than of the protocol:
- The constructor cannot attach arbitrary HTTP headers. You cannot set
Authorization; the browser sends cookies for the host and anOriginheader on its own. The only header-like input you control is the subprotocol list and the URL. - There is no API to send or observe protocol ping and pong frames. The browser answers server pings automatically, but page code cannot initiate one or see that one arrived. Browser-side liveness therefore needs an application message.
close(code, reason)accepts only 1000 or 3000 to 4999 as a code, and the reason must be at most 123 bytes of UTF-8; anything else throws.onerrorcarries no detail, and the HTTP status of a failed handshake is hidden from script, as described above.
Server-side libraries typically expose much more: ping and pong, payload limits, terminate, the buffered byte count. That asymmetry is why the heartbeat and backpressure sections below treat the two ends differently.
Where Does the Socket Sit in the System?
Persistent connections on one side, durable history on the other
Picture the production shape. Browsers connect through a load balancer to a fleet of gateway processes that hold the sockets. The gateways share a message broker for live fan-out and a durable store for history. Two kinds of state sit in that diagram and they behave very differently. The sockets are persistent connections: each one lives on one gateway, in memory, until something breaks it. The store is durable history: it survives every gateway restart. The broker is the transient middle layer that moves events between gateways and is allowed to lose them.
- Active path in this step
- Broken socket
- Idle
1 · Alice sends. Alice's chat.send travels over her long-lived socket through the load balancer to Gateway A. Nothing has been stored or delivered yet.
Step through the five states. Alice's message is persisted once, at one gateway, in one transaction, and the acknowledgment waits for that commit. The stored event is then published to the room, and every gateway holding a member pushes it to its local sockets. That second step is an optimization for latency: if it fails or Bob is not connected, correctness is unaffected because the message is already durable. When Bob reconnects, possibly to a different gateway, he asks for what he missed after his last cursor and is served from the store, not from any socket or broker. Keeping that separation (connections for speed, storage for truth) is what makes the rest of the lesson tractable.
One implication to carry forward: publishing after the commit leaves a window in which the process can crash between the two. For chat this is harmless, since the next resume repairs the gap. Where a lost publish would be a real problem, the outbox pattern closes the window by writing the event to the same transaction and relaying it afterwards.
How Are WebSocket Messages Delivered?
Validation, idempotent storage and acknowledgments that say what they mean
Suppose Alice sends this frame:
{"type":"chat.send","client_message_id":"c-7a2","room_id":"r9","text":"Hello"}The server should validate the schema, the sender identity, room membership, message size and rate limits before anything is stored. It then persists the message under a uniqueness constraint on (sender_id, client_message_id) so a retry cannot create a second copy, assigns a server message id, and sends an application-level acknowledgment:
{"type":"chat.ack","client_message_id":"c-7a2","message_id":"m-r9-118","seq":118,"status":"stored"}The acknowledgment must state what it means. stored may mean a durable database commit; accepted may mean queued; neither implies that any recipient displayed the message. If you later add delivered or read receipts, they are separate events from other people's clients, not upgrades of this one. The server should send the acknowledgment to the sender only once the promise its status names is actually true.
The middle scenario is why the client id exists. If Alice's connection drops after the server commits but before the acknowledgment reaches her, she cannot know whether the message was stored, so she retries. The stable client_message_id makes that retry safe: the duplicate lookup finds the stored row instead of inserting again, and the reply carries the original message_id. This is the same idea as an idempotency key on an HTTP request, with the difference that on a socket you also own the retry timer, the resend-on-reconnect logic and the "no ack within N seconds, assume the connection is dead" rule. A client that sees no acknowledgment on a socket that still reports OPEN should treat the connection as suspect and reconnect, not wait forever.
A storage layer that makes retries safe
The schema below implements the uniqueness constraint and a per-room counter that doubles as the ordering authority discussed in the next section. It runs as written on PostgreSQL.
-- PostgreSQL. Runnable as written.
CREATE TABLE room_counter (
room_id text PRIMARY KEY,
last_seq bigint NOT NULL DEFAULT 0 -- the ordering authority for the room
);
CREATE TABLE chat_message (
room_id text NOT NULL REFERENCES room_counter (room_id),
seq bigint NOT NULL,
message_id text NOT NULL UNIQUE,
sender_id text NOT NULL,
client_message_id text NOT NULL, -- client-generated, e.g. a UUID, unique per sender
body text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (room_id, seq),
UNIQUE (sender_id, client_message_id) -- makes a retried send idempotent
);// Node.js with node-postgres (pg). Runnable given the schema above and a configured Pool.
// A client message ID belongs to one sender AND one immutable room/payload combination.
import pg from 'pg';
const pool = new pg.Pool();
export async function storeMessage({ roomId, senderId, clientId, text }) {
// A competing insert can win after our initial duplicate lookup. Retry with a NEW
// checked-out connection so the previous one is released before another attempt.
for (let attempt = 0; attempt < 3; attempt++) {
const db = await pool.connect();
try {
await db.query('BEGIN');
const dup = await db.query(
'SELECT message_id, seq, room_id, body FROM chat_message WHERE sender_id = $1 AND client_message_id = $2',
[senderId, clientId],
);
if (dup.rowCount > 0) {
const original = dup.rows[0];
if (original.room_id !== roomId || original.body !== text) {
const error = new Error('client_message_id already used for a different message');
error.code = 'IDEMPOTENCY_CONFLICT';
throw error;
}
await db.query('COMMIT');
return { messageId: original.message_id, seq: Number(original.seq),
roomId: original.room_id, text: original.body, duplicate: true };
}
// This row update serializes writers per room; its increment rolls back
// with the transaction if a concurrent duplicate wins.
const bumped = await db.query(
'UPDATE room_counter SET last_seq = last_seq + 1 WHERE room_id = $1 RETURNING last_seq',
[roomId],
);
if (bumped.rowCount !== 1) throw new Error('unknown room');
const seq = Number(bumped.rows[0].last_seq);
const messageId = 'm-' + roomId + '-' + seq;
await db.query(
'INSERT INTO chat_message (room_id, seq, message_id, sender_id, client_message_id, body) VALUES ($1,$2,$3,$4,$5,$6)',
[roomId, seq, messageId, senderId, clientId, text],
);
await db.query('COMMIT');
return { messageId, seq, roomId, text, duplicate: false };
} catch (err) {
await db.query('ROLLBACK');
// Only retry a race on the intended idempotency constraint; never loop on
// unrelated unique-key errors. Finally releases db before the next attempt.
if (err.code === '23505' &&
err.constraint === 'chat_message_sender_id_client_message_id_key' &&
attempt < 2) continue;
throw err;
} finally {
db.release();
}
}
throw new Error('concurrent message retry could not be resolved');
}Three details are deliberate. The duplicate check runs first so a plain retry does not burn a sequence number. The unique constraint is still the real guard, because two concurrent retries can both pass the check; the loser fails with a unique violation, rolls back (returning its sequence number) and re-reads. And the counter update takes a row lock that is held until commit, so within a room sequence numbers are assigned and committed in the same order. The cost is that one room's writes are serialized through one row, which is acceptable for chat rooms and not for a room with thousands of writes per second; that is the case for partitioning discussed under scaling.
The gateway handler
The handler below ties the pieces together: Origin and credential checks during the upgrade, a payload limit, message validation, storage, the acknowledgment and the publish. The authentication and membership helpers are yours to supply. Notice that the ack is sent before the publish, and that publishing again for a duplicate is harmless because clients deduplicate by seq.
// Node.js with the "ws" and "ioredis" packages. authenticate(), isMember(), rateLimiter and onResume()
// (shown in the resume section) are defined elsewhere.
import http from 'node:http';
import { WebSocketServer } from 'ws';
import Redis from 'ioredis';
import { storeMessage } from './store.js';
const pub = new Redis();
const ALLOWED_ORIGINS = new Set(['https://chat.example.com']);
const server = http.createServer((req, res) => {
res.writeHead(404).end();
});
const wss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024 }); // ws defaults to 100 MiB
function reject(socket, status, text) {
socket.write('HTTP/1.1 ' + status + ' ' + text + '\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
socket.destroy();
}
server.on('upgrade', async (req, socket, head) => {
socket.on('error', () => socket.destroy());
try {
const url = new URL(req.url, 'https://chat.example.com');
if (url.pathname !== '/chat/socket') return reject(socket, 404, 'Not Found');
if (!ALLOWED_ORIGINS.has(req.headers.origin)) return reject(socket, 403, 'Forbidden');
const user = await authenticate(req); // session cookie or single-use ticket
if (!user) return reject(socket, 401, 'Unauthorized');
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req, user));
} catch {
reject(socket, 500, 'Internal Server Error');
}
});
function send(ws, event) {
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(event));
}
wss.on('connection', (ws, req, user) => {
ws.user = user;
ws.rooms = new Map(); // roomId -> { cursor, buffer }
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
ws.on('message', async (data, isBinary) => {
ws.isAlive = true; // any inbound traffic proves the peer is alive
if (isBinary) return ws.close(1003, 'text frames only');
let msg;
try { msg = JSON.parse(data.toString()); } catch { return ws.close(1007, 'invalid JSON'); }
try {
if (msg.type === 'chat.send') await onChatSend(ws, msg);
else if (msg.type === 'sync.resume') await onResume(ws, msg);
else if (msg.type === 'hb') send(ws, { type: 'hb.ack' });
else send(ws, { type: 'chat.error', code: 'unknown_type' });
} catch (err) {
send(ws, { type: 'chat.error', client_message_id: msg.client_message_id, code: 'internal' });
}
});
ws.on('close', () => { for (const roomId of ws.rooms.keys()) leaveRoom(ws, roomId); });
});
async function onChatSend(ws, msg) {
const { client_message_id: clientId, room_id: roomId, text } = msg;
const fail = (code) => send(ws, { type: 'chat.error', client_message_id: clientId, code });
if (typeof clientId !== 'string' || clientId.length === 0 || clientId.length > 64) return fail('invalid');
if (typeof roomId !== 'string' || typeof text !== 'string' || text.length === 0 || text.length > 4000) return fail('invalid');
if (!(await isMember(ws.user.id, roomId))) return fail('forbidden');
if (!rateLimiter.allow(ws.user.id)) return fail('rate_limited');
let stored;
try {
stored = await storeMessage({ roomId, senderId: ws.user.id, clientId, text });
} catch (err) {
if (err.code === 'IDEMPOTENCY_CONFLICT') return fail('idempotency_conflict');
throw err;
}
send(ws, { type: 'chat.ack', client_message_id: clientId, message_id: stored.messageId, seq: stored.seq, status: 'stored' });
// A duplicate may repair a missed publication, but always publish the ORIGINAL
// persisted content. Clients dedupe by (room_id, seq); use an outbox for durable delivery.
await pub.publish('room:' + stored.roomId, JSON.stringify({
type: 'chat.message', room_id: stored.roomId, seq: stored.seq, message_id: stored.messageId,
sender_id: ws.user.id, text: stored.text,
}));
}
server.listen(8080);What Ordering Do WebSockets Guarantee?
Order on one connection is not a global order
TCP preserves byte order on an individual connection, and WebSocket preserves the order of messages as delivered on that connection. That does not create a global total order across multiple senders, servers or reconnects. Alice and Carol may send at nearly the same instant through different gateways. Each gateway delivers its own local message immediately and the other after a broker hop, so Bob and Dana, each a perfectly ordered connection, can see the two messages in opposite orders.
Per-connection ordering is real, but the two deliveries took different paths. Without an ordering authority the screens can legitimately differ.
A room that needs consistent ordering needs an ordering authority: server-assigned sequence numbers from one place per room, as in the counter above, or a partition leader that serializes writes for that room. Clients then render by sequence and treat a jump in the numbers as a missing message. Even with that, publish order from several gateways does not have to match commit order on the broker channel, so the receiving side must be prepared to buffer and sort, as the resume handler later does.
If recipients reconnect, they should resume from a durable cursor, meaning the last sequence number they processed, or request a state snapshot. The two sections that follow cover how that works. The alternative, replaying whatever the broker happens to hold, depends on retention you do not control.
How Do You Authenticate and Authorize a Socket?
Cookies need an Origin check, tokens need a safe handoff, and expiry still applies
Because the browser constructor cannot attach headers, the credential has to travel by one of a few routes, each with a cost. Always use wss:// for Internet traffic, enforce message-size limits, and authorize every subscription and action, not just the initial connection. A socket that was allowed to connect an hour ago is not automatically allowed to join a private room now.
- Session cookie. The browser attaches cookies to the handshake automatically, so it is the least code. It also means any page the user visits can try to open a socket to your gateway using that cookie. That is cross-site WebSocket hijacking, and WebSockets are not protected by the same-origin policy the way scripted HTTP reads are. Whether the cookie is attached to a cross-site handshake depends on its
SameSiteattribute and the browser, so do not rely on that; validate theOriginheader against an explicit allow-list. - Short-lived single-use ticket in the URL. The page calls an authenticated HTTP endpoint, receives an opaque ticket that expires in seconds, and passes it as a query parameter on the upgrade. Query strings can end up in access logs, which is why the ticket must be single-use and short-lived. It also solves the "real status codes" problem, because the ticket request is plain HTTP.
- First-message authentication. Accept the upgrade, then require an
authmessage within a few seconds or close. Nothing sensitive leaks into URLs or logs, but unauthenticated sockets consume resources until they authenticate, so cap how many you tolerate and keep the timeout short. - Subprotocol header. Some deployments pass a token in
Sec-WebSocket-Protocolbecause it is the one request header the API lets you set. It works, but it bends a header meant for protocol negotiation and the token then appears in proxy logs, so treat it as a last resort.
Browsers set Origin themselves and page scripts cannot override it, so an allow-list reliably stops other websites from driving a victim's browser into your gateway. It does nothing about non-browser clients, which can send any header, so it complements authentication and never replaces it. Compare with an exact match; a suffix or substring test lets chat.example.com.evil.examplethrough.
// Cross-site WebSocket hijacking defense, in isolation. Browsers set Origin; page JavaScript cannot override it.
const ALLOWED_ORIGINS = new Set(['https://chat.example.com', 'https://app.example.com']);
function originAllowed(req) {
const origin = req.headers.origin;
return typeof origin === 'string' && ALLOWED_ORIGINS.has(origin); // exact match, never endsWith() or includes()
}// Short-lived, single-use ticket: the browser authenticates over ordinary HTTP first, then presents the ticket.
import crypto from 'node:crypto';
import Redis from 'ioredis';
const redis = new Redis();
// 1. Normal HTTP endpoint behind your usual session or bearer-token auth (Express-style).
app.post('/ws-ticket', requireSession, async (req, res) => {
const ticket = crypto.randomBytes(24).toString('base64url');
await redis.set('ws-ticket:' + ticket, req.user.id, 'EX', 30); // valid for 30 seconds
res.json({ ticket });
});
// 2. Gateway side, called from the upgrade handler.
async function authenticate(req) {
const ticket = new URL(req.url, 'https://chat.example.com').searchParams.get('ticket');
if (!ticket) return null;
const userId = await redis.getdel('ws-ticket:' + ticket); // GETDEL needs Redis 6.2+: atomic single use
return userId ? { id: userId } : null;
}Long-lived connections also outlive credentials. A JWT that expires after an hour does not end a socket that stays open for four, and a suspended user keeps receiving events until someone closes the connection. Decide the policy explicitly: close at expiry with a dedicated code so the client can refresh and reconnect, or accept an in-band auth.refresh message, and kick sockets out of rooms when membership is revoked.
// After a successful upgrade: a long-lived socket must not outlive the credential that opened it.
wss.on('connection', (ws, req, user) => {
const remainingMs = Math.max(0, user.expiresAt - Date.now());
ws.expiryTimer = setTimeout(() => ws.close(4401, 'session expired'), remainingMs); // 4xxx: application-defined
ws.on('close', () => clearTimeout(ws.expiryTimer));
});How Should a Client Handle Disconnects and Reconnects?
A state machine, a backoff that does not stampede, and a clean way to stop
A production client handles connecting, open, temporarily disconnected, reconnecting and intentionally closed. Network changes, mobile sleep, server deploys and idle gateway timeouts routinely break connections, so a disconnect is an ordinary event, not an exception. Reconnect attempts use exponential backoff with jitter. The client stops reconnecting when the user logs out or the session is revoked, and it refreshes authentication as appropriate before trying again.
Current state: CONNECTING. Start in CONNECTING: the first new WebSocket() has just been created.
The buttons above only enable the transitions that are legal from the current state, which is the point of writing the lifecycle down as a machine. The failure it prevents is the common one: an onclose handler that always schedules a reconnect, including after the user logged out or the server said the session was revoked. A simple client may look like this:
let socket;
let retryMs = 1000;
let stopped = false;
function connect() {
if (stopped) return;
socket = new WebSocket('wss://chat.example.com/chat/socket');
socket.onopen = () => {
retryMs = 1000;
// authenticate/subscribe according to the server's documented protocol
// and request messages after the last processed server cursor
};
socket.onmessage = (event) => {
const message = JSON.parse(event.data);
handleAndPersistCursor(message);
};
socket.onclose = () => {
if (!stopped) {
const delay = retryMs * (0.8 + Math.random() * 0.4);
setTimeout(connect, delay);
retryMs = Math.min(retryMs * 2, 30000);
}
};
}
function disconnect() {
stopped = true;
socket?.close(1000, 'leaving');
}This is a lifecycle sketch, not a complete secure client. In browsers, the WebSocket constructor cannot attach arbitrary HTTP headers. Cookie-based authentication requires careful Origin validation and defenses against cross-site WebSocket hijacking; token-based approaches need a safe protocol and a refresh strategy. It also has two behaviors worth fixing before it ships. It resets the retry delay inside onopen, so a server that accepts the upgrade and immediately closes (an auth failure after the handshake, say) is retried about once a second forever; reset only after the connection has stayed up for a while. And a plus or minus 20% multiplier barely spreads clients, which matters most at exactly the moment you need spreading.
Why jitter has to be wide
When a gateway restarts, every client it held loses its connection at the same instant and starts the same backoff schedule. With a narrow multiplier, 100,000 clients still retry inside a 0.4-second window on the first round, which is 250,000 attempts per second against whatever is left of the fleet, each one a TLS handshake and an authentication lookup. Full jitter, a uniform random delay between zero and the backoff value, spreads the same clients across the whole interval. The calculator takes your fleet's handshake capacity into account, because the useful rule is not "add jitter" but "make the window at least as long as the time needed to admit everyone".
- ±20% around the backoff value
- Full jitter: uniform 0 to backoff value
- Round #1 backoff value
- 1 s
- ±20% window / rate
- 0.4 s
- 250,000 attempts/s
- Full-jitter window / rate
- 1.0 s
- 100,000 attempts/s
- Time to admit everyone
- 20.0 s
- 100,000 ÷ 5,000 per s
Jitter only helps when the window is at least as long as the time the fleet needs to admit everybody. If the window is shorter, the surplus attempts queue or fail and come back in the next round.
The figure assumes every client is on the same retry round and that attempts spread evenly across the window, which is the idealized case; real fleets have a mix of rounds and the arrival is lumpier. Two other levers matter as much as the formula. The server side can refuse work quickly (accept and close with 1013, or reject early) so that overload does not turn into slow handshakes that hold resources, in the spirit of load shedding. And rolling deploys should shed sockets gradually rather than all at once, shown in the failure-scenarios section. The general retry principles are in Retry & Backoff.
A fuller client
Here is a more complete first version. It keeps a persisted cursor, resends unacknowledged messages after every reconnect, detects gaps from sequence numbers, uses full-jitter backoff that resets only once a connection has proved stable, and stops cleanly. The authentication step uses the ticket from the previous section; currentTicket() and render() are yours.
// Browser JavaScript. A fuller first version of the lifecycle: cursor, unacknowledged sends, gap detection,
// full-jitter backoff that resets only after a connection has proved stable, and a clean stop.
const BASE_MS = 1000;
const CAP_MS = 30000;
const STABLE_MS = 10000; // a connection must survive this long before the backoff resets
const ROOM = 'r9';
let socket = null;
let stopped = false;
let attempt = 0;
let openedAt = 0;
let retryTimer = null;
let cursor = Number(localStorage.getItem('cursor:' + ROOM) ?? 0);
const unacked = new Map(); // client_message_id -> serialized frame, resent after every reconnect
function nextDelay() {
const ceiling = Math.min(CAP_MS, BASE_MS * 2 ** attempt);
attempt += 1;
return Math.random() * ceiling; // full jitter: uniform in [0, ceiling)
}
function connect() {
if (stopped) return;
socket = new WebSocket('wss://chat.example.com/chat/socket?ticket=' + currentTicket());
socket.onopen = () => {
openedAt = Date.now();
socket.send(JSON.stringify({ type: 'sync.resume', room_id: ROOM, after_seq: cursor }));
for (const frame of unacked.values()) socket.send(frame); // safe: client_message_id deduplicates
};
socket.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'chat.ack') {
unacked.delete(msg.client_message_id);
} else if (msg.type === 'chat.message') {
if (msg.seq <= cursor) return; // duplicate or replay overlap
if (msg.seq > cursor + 1) { // a gap: ask the server to fill it from the store
socket.send(JSON.stringify({ type: 'sync.resume', room_id: ROOM, after_seq: cursor }));
return;
}
render(msg);
cursor = msg.seq;
localStorage.setItem('cursor:' + ROOM, String(cursor));
}
};
socket.onclose = (event) => {
if (openedAt && Date.now() - openedAt > STABLE_MS) attempt = 0;
openedAt = 0;
if (stopped || event.code === 4401) return; // 4401: the server says our session is over
retryTimer = setTimeout(connect, nextDelay());
};
}
function sendChat(text) {
const id = crypto.randomUUID();
const frame = JSON.stringify({ type: 'chat.send', client_message_id: id, room_id: ROOM, text });
unacked.set(id, frame);
if (socket?.readyState === WebSocket.OPEN) socket.send(frame);
}
function disconnect() {
stopped = true;
clearTimeout(retryTimer);
socket?.close(1000, 'leaving');
}How Does a Client Catch Up After a Reconnect?
Cursor-based resume from durable storage, with a snapshot as the fallback
A new connection has no memory of the old one, so the first thing a client does after the handshake is tell the server where it stopped. The cursor is the highest contiguous room sequence number it has processed, stored on the client (the example above uses localStorage). The server answers with the missing messages from the durable store, then switches the socket to live delivery.
The order of operations in the first scenario is the whole trick. If the gateway read history first and subscribed afterwards, anything published between those two moments would be missed. So it subscribes first, buffers live events, reads history after the cursor, sends it, and only then flushes the buffered events, skipping any whose sequence number the replay already covered. Every message lands exactly once at the display level, and no gap is possible, even though two different mechanisms delivered it.
-- Catch-up read for one room. (room_id, seq) is the primary key, so this is a range scan.
SELECT seq, message_id, sender_id, body
FROM chat_message
WHERE room_id = $1 AND seq > $2
ORDER BY seq
LIMIT 500;// Per-gateway room hub: the topic-to-gateway subscription map. A gateway subscribes to a room's channel only
// while at least one of its own sockets is in that room. Uses a dedicated Redis connection for subscribing.
import Redis from 'ioredis';
const sub = new Redis();
const rooms = new Map(); // roomId -> { members: Set<ws>, ready: Promise }
export async function joinRoom(ws, roomId) {
let room = rooms.get(roomId);
if (!room) {
room = { members: new Set(), ready: sub.subscribe('room:' + roomId) };
rooms.set(roomId, room);
}
room.members.add(ws);
await room.ready; // do not read history before the subscription is live
}
export function leaveRoom(ws, roomId) {
const room = rooms.get(roomId);
if (!room) return;
room.members.delete(ws);
ws.rooms.delete(roomId);
if (room.members.size === 0) {
rooms.delete(roomId);
sub.unsubscribe('room:' + roomId).catch(() => {});
}
}
sub.on('message', (channel, payload) => {
const roomId = channel.slice('room:'.length);
const room = rooms.get(roomId);
if (!room) return;
const event = JSON.parse(payload);
for (const ws of room.members) deliver(ws, roomId, event);
});
function deliver(ws, roomId, event) {
const state = ws.rooms.get(roomId);
if (!state) return;
if (state.buffer) { state.buffer.push(event); return; } // still replaying history for this socket
if (event.seq <= state.cursor) return; // already sent during replay
state.cursor = event.seq; // the client detects gaps and asks for a resume
send(ws, event);
}// The server side of sync.resume: subscribe first, then replay history, then flush what arrived meanwhile.
async function onResume(ws, { room_id: roomId, after_seq: afterSeq }) {
if (!Number.isSafeInteger(afterSeq) || afterSeq < 0 || !(await isMember(ws.user.id, roomId))) {
return send(ws, { type: 'chat.error', code: 'invalid_resume' });
}
const state = { cursor: afterSeq, buffer: [] }; // live events queue in buffer until replay finishes
ws.rooms.set(roomId, state);
await joinRoom(ws, roomId); // subscribe BEFORE reading history
for (;;) {
const { rows } = await pool.query(CATCH_UP_SQL, [roomId, state.cursor]); // CATCH_UP_SQL is the query above
for (const r of rows) {
send(ws, { type: 'chat.message', room_id: roomId, seq: Number(r.seq), message_id: r.message_id,
sender_id: r.sender_id, text: r.body });
state.cursor = Number(r.seq);
}
if (rows.length < 500) break;
}
// Publishes from different gateways may reach the channel out of seq order: sort, then skip what replay covered.
state.buffer.sort((a, b) => a.seq - b.seq);
for (const ev of state.buffer) {
if (ev.seq > state.cursor) { send(ws, ev); state.cursor = ev.seq; }
}
state.buffer = null; // from now on deliver() sends live events directly
send(ws, { type: 'sync.caught_up', room_id: roomId, through_seq: state.cursor });
}The second scenario is the retention limit. History is not kept forever, and a client that was offline for a month may ask for a sequence the store no longer has. Detect that (compare the cursor against the oldest retained sequence, which the replay code above does not show), tell the client with a reset message, and let it fetch a snapshot over ordinary HTTP that records the sequence it reflects. From that point it resumes with a normal cursor. For presence-style or cursor-position data there is no history to replay at all: the new connection sends the current state and the client replaces whatever it had.
How Do You Detect Dead and Half-Open Connections?
Protocol pings on the server, application heartbeats in the browser
A connection can appear open to one endpoint even when the network path has silently failed. A phone moves out of coverage, a laptop sleeps, or a NAT device drops its mapping, and no FIN or RST ever reaches the other side. Until it tries to write, that endpoint has no reason to doubt the socket. Left alone, the gateway accumulates subscriptions, buffers and memory for clients that left long ago.
Servers therefore send protocol ping frames and expect pong replies within a deadline. The browser answers pings automatically at the implementation level, with no page code involved, but JavaScript cannot initiate protocol pings through the standard API and cannot see them. For end-to-end liveness measured from the browser, and for carrying domain data such as a client timestamp, use an application heartbeat message.
Both directions matter. The server-driven ping is how the gateway reclaims dead sockets, and it also keeps idle timers in proxies and NAT devices from expiring because the path is never silent. The browser-driven heartbeat is how the page notices a dead path before the user does. Two cautions on the client side: a browser may leave a socket in CLOSING for a long time on a dead path, so a watchdog should abandon the old object and reconnect immediately, and hidden tabs and sleeping devices throttle JavaScript timers, so the server's tolerance has to allow for heartbeats arriving late.
// Server: protocol-level ping/pong. Straight from the pattern in the "ws" documentation.
const HEARTBEAT_MS = 30000;
const heartbeat = setInterval(() => {
for (const ws of wss.clients) {
if (!ws.isAlive) { ws.terminate(); continue; } // no pong and no traffic since the previous tick
ws.isAlive = false;
ws.ping();
}
}, HEARTBEAT_MS);
wss.on('close', () => clearInterval(heartbeat));// Browser: JavaScript cannot send protocol pings, so use an application-level heartbeat and a watchdog.
const HB_EVERY_MS = 15000;
const HB_DEAD_AFTER_MS = 45000;
let lastHeardAt = Date.now(); // set to Date.now() in socket.onmessage and socket.onopen
setInterval(() => {
if (socket?.readyState !== WebSocket.OPEN) return;
if (Date.now() - lastHeardAt > HB_DEAD_AFTER_MS) {
// On a dead path close() may leave the socket in CLOSING for a long time: detach it and reconnect now.
const dead = socket;
dead.onopen = dead.onmessage = dead.onclose = null;
dead.close();
retryTimer = setTimeout(connect, nextDelay());
return;
}
socket.send('{"type":"hb"}'); // the server answers with {"type":"hb.ack"}
}, HB_EVERY_MS);Choose intervals from three constraints: the shortest idle timeout anywhere on the path, battery and radio cost on mobile, and how long a dead connection may linger before cleanup. Too-frequent heartbeats waste bandwidth and battery; too-infrequent checks delay cleanup. The tick-based server loop above terminates a socket that missed one full interval, so detection takes between one and two intervals. A variant that starts a per-socket timeout when each ping is sent detects after the interval plus the timeout instead. Whichever you use, close dead connections and release subscriptions and memory promptly; and with a very large fleet, spread the pings across the interval rather than looping over every socket in one tick.
- Pings per second
- 6,667
- and as many pongs
- Control-frame bytes/s
- 53,333
- frame bytes only, no TCP/TLS
- Dead-peer detection
- 30–60 s
- tick-based check
- Versus idle timeout
- Survives one lost ping
The frame bytes are tiny; the real per-ping cost is a packet in each direction plus TLS record overhead, and on mobile the radio wake-ups. Detection of a dead peer with a ping-every-tick check is between one and two intervals, because the first tick that notices must wait for the next tick to give the pong a chance.
How Do WebSockets Scale Across Servers?
Cross-gateway fan-out, routing strategies and a capacity model
With one gateway, room membership and sockets can live in memory and a broadcast is a loop. With 20 gateways, Alice and Bob may be connected to different instances, and publishing only to local sockets misses everyone on the others. A shared broker, a partitioned event stream or a routing service has to carry room events to the gateways that hold relevant subscriptions. Durable messages belong in a suitable database or event store, not solely in gateway memory, which would lose them with the process.
There are several fan-out strategies, each with a different cost:
- Broadcast every event to every gateway and filter locally. Simple and robust, since no subscription state can go stale, but the broker-to-gateway traffic grows with gateway count regardless of who cares.
- Maintain a distributed topic-to-gateway subscription map. Each gateway subscribes to a room's channel only while it has local members, as in the hub above. It reduces unnecessary traffic and accepts the complexity of stale membership: a gateway that crashed without unsubscribing, or a subscribe that has not propagated yet.
- Partition rooms. Assign each room to an owning gateway or shard (for example with consistent hashing) so related messages share an ordering authority. Plan for very large rooms and hot partitions, since one room cannot be split without giving up its single order.
- Gateways with members
- 18.5
- of 20
- Broadcast deliveries
- 4,000/s
- Routed deliveries
- 3,692/s
- Broadcast waste
- 8 %
- gateways with no member
Routing saves broker-to-gateway traffic for small rooms and little for large ones: when members outnumber gateways by a wide margin, every gateway has someone and the two strategies converge. The socket writes never go away.
The calculator uses a deliberately simple model: members land on gateways uniformly at random. Under that model topic routing helps most for small and medium rooms and converges to broadcast for large ones, and it never reduces the per-socket writes. Real placement is not uniform (users from one office share a region), so treat the numbers as the shape of the trade-off, not a prediction. The pub/sub lesson covers the broker side; note that a fire-and-forget channel such as Redis pub/sub is at-most-once and keeps nothing for a subscriber that is down, which is exactly why history comes from the store and live fan-out is only an accelerator.
Sticky load balancing can reduce reconnect movement by returning a client to the gateway it had before, but it does not solve cross-instance delivery or failover: when that gateway dies the client moves anyway, and Alice and Bob can still be on different instances. Treat stickiness as a cache hit, never as a correctness mechanism.
A rough capacity model
Start with active_connections × per_connection_memory, then add per-room subscription state, write buffers, heartbeat traffic and broker subscriptions. For example, 200,000 connections at an illustrative 40 KiB of total connection-related memory consume about 7.6 GiB before application caches, runtime overhead and headroom. Actual memory per connection varies greatly with runtime, TLS library, compression and what you attach to each socket; measure it on the chosen stack with realistic traffic, not an idle benchmark. Other ceilings arrive before memory does on some stacks: one file descriptor per socket, and, between a load balancer and its backends, the roughly 28,000 ephemeral ports Linux offers by default for each source-address and destination-address pair.
- Connection memory
- 7.63 GiB
- 200,000 × 40 KiB
- With headroom
- 7.63 GiB
- Room outbound frames
- 250,000/s
- 50,000 × 5.0
- Room outbound data
- 608.0 Mbit/s
- 4-byte frame header, no TLS/TCP
The two halves answer different questions. Memory scales with how many sockets are open and idle; bandwidth and CPU scale with how many frames a room produces times its audience. One popular room can out-cost hundreds of thousands of quiet sockets, which is why connection count alone is a poor capacity number.
Connection count alone is not enough. With the preset inputs, 200,000 idle sockets cost about 7.6 GiB of memory and almost no bandwidth, while one room of 50,000 members receiving 5 messages per second of 300 bytes pushes 250,000 frames per second, about 76 MB/s or 608 Mbit/s of frame data before TLS and TCP overhead. One busy room can consume more network and CPU than thousands of idle sockets, so size the fleet on both axes and test with your worst realistic room, not an average one.
What Happens When a Client Cannot Keep Up?
Bounded queues, coalescing and disconnect-with-cursor
A browser on a weak network may receive more slowly than the server produces. A server that keeps queuing outgoing messages without a bound eventually exhausts memory, and it takes down every other client on that gateway with it. Bound per-connection buffers, monitor queue depth, and define a policy for the moment the bound is hit.
- Queue growth
- 20 msg/s
- Time to hit limit
- 25.0 s
- Unbounded after 60 s
- 1,200 msgs
- 1,000 such clients, unbounded
- 585.9 MiB
- payload bytes only
The policy depends on what the data is. For ephemeral values such as cursor positions or typing indicators, coalesce or drop superseded updates: only the newest position matters, so keep one per key and send on a timer. For durable chat messages, preserve them in storage and disconnect the slow socket with a resume cursor, rather than pretending every queued frame will eventually arrive. The client reconnects, asks for messages after its cursor and receives them at whatever pace it can sustain, as a paged read, which is flow control the protocol itself lacks. The wider pattern is described in Backpressure.
// Server: refuse to queue without limit. Replaces the plain send() used by deliver().
const MAX_BUFFERED_BYTES = 1024 * 1024; // 1 MiB waiting in this socket's send queue
function sendBounded(ws, event) {
if (ws.readyState !== ws.OPEN) return;
if (ws.bufferedAmount > MAX_BUFFERED_BYTES) {
ws.terminate(); // a close handshake would queue behind the backlog; resume from cursor instead
return;
}
ws.send(JSON.stringify(event));
}
// Ephemeral data: keep only the newest value per key and flush on a timer.
function flushCursors(ws) {
if (ws.pendingCursors.size === 0 || ws.bufferedAmount > 64 * 1024) return; // skip a tick; newer positions replace older
send(ws, { type: 'cursors', positions: Object.fromEntries(ws.pendingCursors) });
ws.pendingCursors.clear();
}
setInterval(() => wss.clients.forEach(flushCursors), 50);On the browser side, WebSocket.bufferedAmount reports queued bytes for outgoing data. It lets you stop a runaway sender, but it does not tell you whether the server has read what you sent, and the standard browser WebSocket API does not provide general automatic receive-side backpressure: if the server sends faster than the page processes, messages queue in the browser. The newer WebSocketStream API is designed to fix that, but its browser availability is limited, so do not assume it is universally deployable. Application protocols need bounded payloads, rate limits and flow-control strategies appropriate to the workload; see Rate Limiting for per-user send limits.
// Browser: bufferedAmount reports bytes queued for sending, not whether the peer has read them.
function trySend(frame) {
if (socket.readyState !== WebSocket.OPEN) return false;
if (socket.bufferedAmount > 256 * 1024) return false; // let the caller drop, coalesce or retry later
socket.send(frame);
return true;
}What Do You Monitor and Test?
Signals that reveal trouble early, and failure scenarios to rehearse
Track connection attempts, successful handshakes, active connections, disconnect reasons, p95 message latency, fan-out delay, message queue depth, reconnect frequency, heartbeat timeouts, broker lag and authorization failures. Several of these are only meaningful in pairs. Attempts versus successful handshakes shows whether a deploy is turning into a storm; active connections against disconnect reasons (by close code) separates user churn from your own gateways closing sockets; fan-out delay against broker lag shows whether slowness lives in the broker or in the slow-consumer path.
Then test the failures, because most of them only appear when forced:
- Server restart. Do clients spread their reconnects, resume from their cursor, and avoid duplicates? The drain procedure below is the controlled version of this.
- Broker outage. Live fan-out stops while the store keeps accepting writes. Check that acks still mean "stored", that gateways resubscribe on recovery, and that clients repair gaps from sequence numbers rather than waiting for events that will never arrive.
- Client sleep. A laptop lid closes for an hour. On wake, the socket may look open for a while; the watchdog should notice and the resume path should serve the hour.
- Message duplication. Resend an acknowledged message, replay a resume that overlaps live events, reconnect mid-send. The visible result must stay one message.
- Slow receivers. Throttle one client to a trickle and confirm that its queue is bounded and nobody else notices.
- Cross-instance delivery. Put two users on different gateways on purpose and verify both the live path and the reconnect path, since a single-gateway test hides the broker entirely.
A good demo shows messages moving while both browsers are online. A good production design also explains what happens while one browser is offline, which is the part the architecture figure's fourth and fifth states describe.
Draining a gateway for a deploy
A rolling deploy disconnects every socket on the instance being replaced. Done carelessly, the whole gateway's population reconnects in the same second, which is the reconnect storm again. Failing the readiness check first stops new upgrades from being routed to the instance. Closing sockets in slices with close code 1012 then spreads the reconnects, and the clients' own jittered delay spreads them further. The grace period given by the orchestrator has to be longer than the drain window, or the process is killed mid-drain.
// Graceful deploy: stop taking new sockets, then shed existing ones gradually with close code 1012.
let ready = true; // /readyz returns 503 while this is false
async function drain(windowMs = 60000, tickMs = 1000) {
ready = false;
const sockets = [...wss.clients];
const ticks = Math.max(1, Math.floor(windowMs / tickMs));
const perTick = Math.max(1, Math.ceil(sockets.length / ticks));
for (let i = 0; i < sockets.length; i += perTick) {
for (const ws of sockets.slice(i, i + perTick)) ws.close(1012, 'service restart');
await new Promise((resolve) => setTimeout(resolve, tickMs));
}
}
process.on('SIGTERM', async () => {
await drain();
server.close();
process.exit(0);
});When Are WebSockets the Right Choice?
Match the transport to the direction of data and the cost of long-lived state
Choose WebSockets when the client also sends frequent, latency-sensitive messages, not only when it receives. Chat, collaborative editing, multiplayer state, live trading screens and interactive consoles fit: both sides talk continuously, and one connection removes the per-message request overhead. For a collaborative editor or interactive chat WebSockets can simplify the two-way exchange, while a durable store and cursor-based catch-up handle the disconnects the socket cannot.
Choose something simpler when the traffic is mostly server to client. A build log or a notification stream is one-way, and SSE delivers it over ordinary HTTP with built-in reconnection and a resume header. A status that changes every few minutes is often best served by polling, which needs no connection state at all, and long polling covers the middle ground when push-like latency is wanted but connections cannot be held for long. Plain CRUD with no real-time requirement does not need any of them.
Whatever the transport, online indicators are a separate problem: derive them from sessions and heartbeats as described in Presence, rather than reading them off the socket. Before committing, be honest about what you are taking on: sockets that are stateful and pinned to a process, a heartbeat and drain procedure, a reconnect path that must never stampede, an ordering authority, a store that can serve any cursor, and per-connection limits. The transport is the easy part; the operating model is the work.
Key Takeaways
An operating checklist for a WebSocket gateway
- The socket is only a transport: pair it with durable storage and cursor-based catch-up, and make acknowledgements say 'stored', not 'received by a gateway'.
- Order is per connection, not global: assign sequence numbers in an ordering authority, and have clients dedupe and repair gaps from them.
- Authenticate at the handshake (check Origin for cookie auth, use a single-use ticket or equivalent safe handoff for tokens) and still handle expiry on a connection that stays open.
- Detect half-open connections with server pings and a pong deadline, plus an application heartbeat for browser-side liveness. The browser API cannot send or observe protocol pings.
- Reconnect with wide jitter and a cursor, not a fixed delay, and drain gateways gradually on deploys so clients do not stampede the replacements.
- Bound each client's outbound queue; coalesce or disconnect-with-cursor a slow receiver instead of letting it slow the gateway.
- Cross-gateway delivery needs a broker or routing layer, so test with users on different gateways, and monitor handshake attempts versus successes, close codes, fan-out delay and broker lag.