Ch. 20 · Networking

WebSocket Lifecycle: Handshake, Heartbeats and Close Codes

The WebSocket lifecycle from the 101 handshake to heartbeats, close codes and resuming after a drop, plus which server owns each connection.

~7 min readadvancedupdated Oct 11, 2026

Opening a WebSocket takes one line of browser code. Keeping it working for hours is the real job. A WebSocket connection has a lifecycle: a handshake, a long middle where either side can vanish without saying so, a close that may or may not be clean, and a reconnect that has to pick up where things left off. On the server, every connection also belongs to exactly one process, which shapes how you scale and deploy.

Interview questions on WebSockets usually start with “how is it different from HTTP?” and quickly reach the lifecycle: how do you detect a dead client, what happens during a deploy, how do you avoid losing messages? This note runs each stage with the ws package on the server and Node’s built-in WebSocket client.

The opening handshake

A WebSocket starts life as an HTTP/1.1 GET with upgrade headers. curl can send one using the sample key from RFC 6455:

curl -si http://localhost:8100/ -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
  -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ=='
Terminal
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Text

101 Switching Protocols means that from now on, the TCP connection carries WebSocket frames instead of HTTP: text, binary, ping, pong and close frames, in both directions, whenever either side likes. The accept value is the SHA-1 of the client’s key joined with a fixed GUID, base64-encoded. It proves the server understood the WebSocket handshake rather than some other HTTP server echoing headers. It isn’t authentication.

Origin checks are your job

Here’s the security point interviewers most want to hear: CORS doesn’t apply to WebSockets. A page on any site can open a WebSocket to your server, and the browser attaches your user’s cookies to the handshake. If your socket authenticates by cookie and doesn’t check where the page came from, a malicious site can open a socket as the user and read everything it receives. This is called cross-site WebSocket hijacking.

The browser does send an Origin header, which the server must check before accepting:

const ALLOWED_ORIGINS = new Set(['http://localhost:5173', undefined]); // undefined: non-browser test clients

const wss = new WebSocketServer({
  port: 8100,
  verifyClient: ({ origin }) => ALLOWED_ORIGINS.has(origin),   // browsers send Origin; CORS does not apply here
});
JavaScript

The same handshake with -H 'Origin: https://evil.test' got HTTP/1.1 401 Unauthorized. In production, drop the undefined entry unless non-browser clients authenticate some other way, and authenticate the user in the handshake itself. Connections can outlive the token that opened them, so decide what happens when a session expires: close the socket with a policy code, or require the client to send a fresh token.

Knowing when a connection is dead

TCP only reports a closed connection when the other side says goodbye, or when sending data eventually fails. A phone that walks out of signal range, a laptop that sleeps, or a NAT that silently forgets the mapping leaves a half-open connection: the server still has a socket, buffers and per-user state, and keeps writing into nothing.

The fix is a heartbeat. The server pings every client on a timer and drops any that didn’t answer the previous ping:

wss.on('connection', (ws) => {
  ws.isAlive = true;
  ws.on('pong', () => { ws.isAlive = true; });
});

setInterval(() => {
  for (const c of wss.clients) {
    if (!c.isAlive) { console.log('server: no pong since last ping, terminating'); c.terminate(); continue; }
    c.isAlive = false;
    c.ping();
  }
}, 1000);
JavaScript

To test it, the client process was frozen with kill -STOP 1.5 seconds in, which keeps its TCP connection technically open but unable to answer, then thawed with kill -CONT three seconds later:

--- server
server: client connected (1 open)
server: no pong since last ping, terminating
server: client closed with code 1006
server: client connected (1 open)
server: client resumes after seq 14, replaying 14
--- client
client: connected (attempt 1)
client: closed, code 1006, last seq 14; reconnecting in 821 ms
client: connected (attempt 2)
client: received 35 messages, last seq 37
Text

The server cleaned up the frozen client within two ping intervals. When the client woke, it found its connection gone, reconnected, and recovered the 14 messages it missed.

A one-second ping is only for the demo. In production, pick an interval below the shortest idle timeout on the path (load balancers often default to 60 seconds, some NATs are shorter), so 20 to 30 seconds is common. The heartbeat then does two jobs: it keeps the path alive and it detects dead peers. Browsers answer ping frames automatically but the JavaScript API can’t send them, so a browser client that wants to detect a dead server sends small application-level heartbeat messages instead.

Closing, and what the codes tell you

A clean close is a handshake: one side sends a close frame with a code, the other replies, then TCP closes. The codes are worth recognising in logs:

  • 1000: normal closure.
  • 1001: going away, such as a server shutting down for a deploy or a browser leaving the page.
  • 1006: abnormal closure. It’s never sent on the wire; it’s what the local side reports when the connection died without a close frame. Both the simulated network drop and the frozen client above produced 1006.
  • 1008 policy violation and 1011 internal error, plus 4000 to 4999 for your own application’s codes.

A dashboard that splits closes by code is useful: a burst of 1001 lines up with a deploy, while a rise in 1006 points at the network or at servers dying abruptly.

Reconnecting without losing messages

The client reconnects with exponential backoff and random jitter, and tells the server the last sequence number it processed:

ws.onopen = () => {
  attempt = 0;
  if (lastSeq) ws.send(JSON.stringify({ type: 'resume', after: lastSeq }));
};
ws.onmessage = (e) => {
  const m = JSON.parse(e.data);
  if (m.seq <= lastSeq) return;                 // duplicate after resume: ignore
  lastSeq = m.seq;
};
ws.onclose = (e) => {
  const delay = Math.min(1000 * 2 ** attempt, 30000) * (0.5 + Math.random() / 2); // backoff with jitter
  attempt++;
  setTimeout(connect, delay);
};
JavaScript

The server keeps the last 100 messages and replays anything after the client’s number. With the server dropping the connection without a close frame after about a second:

client: connected (attempt 1)
client: closed, code 1006, last seq 5; reconnecting in 721 ms
client: connected (attempt 2)
server: client resumes after seq 5, replaying 4
Text

The jitter matters at scale. When a server holding 20,000 connections restarts, clients without jitter all reconnect in the same instant, and the stampede can knock over the next server.

Two simplifications in this demo would need fixing in production. If a client has been away longer than the history window, the server should send a full snapshot instead of a partial replay. And there’s a race: a live message can reach a freshly connected socket before the replay, making the client skip the replayed ones as duplicates. A real server replays first and only then adds the socket to the live broadcast set.

Who owns the connection

Each WebSocket lives on one server process for its whole life, unlike stateless HTTP requests that any instance can handle. That has three consequences:

  • Cross-server delivery. If user A’s socket is on server 3 and an event for A arrives at server 7, server 7 can’t write to it. Instances share events through a pub/sub backplane (Redis, NATS or Kafka), and each one forwards to the sockets it owns.
  • Deploys. Killing a process drops every connection on it at once. Drain instead: stop accepting new connections, close existing ones with 1001 in batches, and let the backoff spread reconnects across the remaining servers.
  • Slow consumers. A client on a bad connection can’t read as fast as you send, and messages pile up in server memory (the ws library reports the backlog as bufferedAmount on each socket). Set a limit, then drop non-essential messages or disconnect the client so one slow phone can’t use up a server’s memory.

Interview practice

Design the WebSocket layer for live price updates to 200,000 clients spread across 20 servers. You deploy daily, and many clients are phones on unreliable networks.

Answer

Each server holds around 10,000 sockets and subscribes to the price topics its clients need on a backplane such as Redis pub/sub or NATS. Every price update carries a per-instrument sequence number, and servers keep a short replay window per instrument. The handshake checks Origin against an allowlist and authenticates with a short-lived token; when the token expires, the server closes with an application code that tells the client to refresh and reconnect.

For dead connections, the server pings every 25 seconds and terminates clients that miss a pong, which stays under the load balancer’s idle timeout and frees memory held by phones that vanished. Clients reconnect with exponential backoff and jitter, then send their last sequence number per instrument. The server replays from the window, or sends a snapshot if they’ve been gone too long, before adding the socket to live delivery.

For deploys, a server being replaced stops taking new connections and closes existing ones with 1001 a few hundred at a time over a minute or two, so reconnects spread across the fleet instead of arriving as one spike. I’d cap per-connection send buffers and drop intermediate price ticks for slow clients, since only the latest price matters, and I’d watch close codes, open connection counts and buffer sizes per server.

Keep reading

More in Networking

read ✓Networking · mid

Networking: DNS Caching and TTL

See how resolvers count TTLs down, why NXDOMAIN answers are cached too, and how to schedule a DNS cutover so stale answers expire.

~7 min readread →
read ✓Networking · easy

Networking: DNS Record Types

Read real dig output for A, AAAA, CNAME, MX, TXT, NS, SOA and CAA records, and learn which combinations DNS rejects and why.

~6 min readread →
read ✓Networking · mid

Networking: HTTP Redirects

Test 301, 302, 303, 307 and 308 against a local Node server with curl and fetch, and see which codes turn a POST into a GET and why.

~6 min readread →
esc