On this pageOPENCLOSE
WebSocket from the wire up
Upgrade handshakes, frames, heartbeats, close codes, reconnection, rooms, and scaling a real WebSocket service.
Goal
- 01Trace the HTTP Upgrade that turns one request into a full-duplex WebSocket connection.
- 02Design frames, heartbeats, close handling, rooms, and validation that survive real networks.
- 03Avoid the production failures that make a WebSocket service look fine in development and collapse under load.
The concept, plainly
A WebSocket begins life as an HTTP/1.1 request with `Upgrade: websocket`, `Connection: Upgrade`, a random `Sec-WebSocket-Key`, and a protocol version. A server that supports the upgrade replies with `101 Switching Protocols`, a computed `Sec-WebSocket-Accept`, and then stops speaking HTTP. The TCP connection is now a WebSocket stream. If you are behind an HTTP/2 or HTTP/3 endpoint, the same concepts are carried through an extended CONNECT flow, but application code still uses the familiar WebSocket API.
The protocol carries small **frames**, each with an opcode, payload length, and optional masking. Text frames contain UTF-8; binary frames hold bytes; continuation frames compose large messages; control frames handle ping, pong, and close. Browsers must mask frames they send to servers; servers must not mask frames they send. One application message may be split across several frames, so a size limit belongs on the assembled message, not only on a single frame.
**Heartbeats** detect a connection that looks open to the operating system but is dead in reality — a sleeping laptop, a dead NAT mapping, or a silently dropped mobile connection. Protocol-level ping/pong frames keep the path warm, and an application-level ping with a timeout proves that both the network and the server event loop are alive. If you only rely on TCP, a half-open socket can hang forever. The browser WebSocket API does not expose raw ping frames, so browser clients usually send a small application heartbeat and expect a reply.
**Close codes** are part of the contract. `1000` is normal closure, `1001` means the page is going away, `1006` is a local observation of an abnormal close and must never be sent, `1008` signals a policy violation, and `1011` signals an unexpected server error. Codes `4000–4999` are application-defined. Do not close with `1005` or `1006`; those values are reserved. On the client, distinguish deliberate closes from network failures so you do not reconnect after logout or a permanent auth rejection.
**Rooms** are a server-side data structure, not a browser concept. Keep a map of room id to sockets, broadcast only to members, and remove sockets on every exit path. Validate every inbound message with a schema such as Zod, enforce a maximum size, rate-limit expensive event types, and authenticate before allowing a join. URLs end up in logs, so prefer cookies or a short-lived ticket over putting long-lived tokens in the query string.
**Reconnection** should use exponential backoff with jitter, capped at a sensible maximum, and should stop when the close code says the failure is permanent. If messages matter, include a sequence number or cursor so the client can request missed events after reconnect. When one process is no longer enough, WebSocket servers need shared room state or a pub/sub layer such as Redis; broadcasts published on one instance must reach subscribers on the others. Sticky sessions help only when the state cannot be shared.
See it move
Interactive diagram
Upgrade, frames, heartbeat, close
HTTP Upgrade request
The browser sends GET /ws with Upgrade: websocket, Connection: Upgrade, Sec-WebSocket-Key, and Sec-WebSocket-Version: 13.
The first exchange is HTTP. Everything after 101 is framed WebSocket traffic over the same TCP connection.
Behind the scenes
The socket is stateful, so your architecture changes
HTTP requests can land on any healthy server. A WebSocket connection belongs to one process, and messages sent from another instance do not automatically reach it. Load balancers need Upgrade support, idle timeouts must be longer than your heartbeat interval, and deploys need a plan for draining or reconnecting sockets. Rooms and presence live in memory unless you move them to Redis or a database. That statefulness is the price of low-latency push.
Frames, backpressure, and memory
The browser exposes complete messages, not individual frames, so an attacker can hide a huge message behind many continuation frames. Set maxPayload on the server and validate the parsed result. When you call send faster than the network drains, `ws` queues data and `bufferedAmount` grows; slow clients can consume memory. Skip or coalesce low-value updates for backed-up sockets, cap per-room fan-out, and never let an unbounded room list or event history live forever.
Auth, reconnect, and exactly-once illusions
Authenticate the upgrade request with a cookie or short-lived ticket, then authorize each room action. A reconnecting client is a new socket; it has no memory of its previous subscriptions unless you rebuild them. Use a resume cursor for missed messages, idempotency keys for commands, and server timestamps for ordering. WebSocket gives you transport reliability, not business-level exactly-once delivery.
Code walkthrough
A production-shaped skeleton. Note the maxPayload limit, origin check, validated discriminated union, heartbeat sweep, and cleanup in a single close handler.
import { WebSocketServer, WebSocket } from "ws";import { z } from "zod";const ClientMessage = z.discriminatedUnion("type", [z.object({ type: z.literal("join"), room: z.string().min(1).max(32) }),z.object({ type: z.literal("chat"), room: z.string(), text: z.string().max(2000) }),z.object({ type: z.literal("ping"), t: z.number() }),]);type Client = { ws: WebSocket; id: string; rooms: Set<string>; alive: boolean };const wss = new WebSocketServer({ port: 8081, maxPayload: 64 * 1024 });const clients = new Map<WebSocket, Client>();const rooms = new Map<string, Set<Client>>();wss.on("connection", (ws, request) => {const origin = request.headers.origin;if (process.env.ALLOWED_ORIGIN && origin !== process.env.ALLOWED_ORIGIN) {ws.close(1008, "Origin not allowed");return;}const client: Client = { ws, id: crypto.randomUUID(), rooms: new Set(), alive: true };clients.set(ws, client);ws.on("pong", () => { client.alive = true; });ws.on("message", (raw) => {let input: unknown;try { input = JSON.parse(raw.toString()); } catch { return ws.close(1008, "Invalid JSON"); }const parsed = ClientMessage.safeParse(input);if (!parsed.success) return ws.close(1008, "Invalid message");if (parsed.data.type === "join") {const members = rooms.get(parsed.data.room) ?? new Set<Client>();members.add(client);rooms.set(parsed.data.room, members);client.rooms.add(parsed.data.room);ws.send(JSON.stringify({ type: "joined", room: parsed.data.room }));}if (parsed.data.type === "chat") {const members = rooms.get(parsed.data.room);if (!members?.has(client)) return ws.close(1008, "Join the room first");for (const member of members) {if (member.ws.readyState === WebSocket.OPEN) {member.ws.send(JSON.stringify({ type: "chat", from: client.id, text: parsed.data.text }));}}}if (parsed.data.type === "ping") {ws.send(JSON.stringify({ type: "pong", t: parsed.data.t }));}});ws.on("close", () => {for (const room of client.rooms) {const members = rooms.get(room);members?.delete(client);if (members?.size === 0) rooms.delete(room);}clients.delete(ws);});});setInterval(() => {for (const client of clients.values()) {if (!client.alive) { client.ws.terminate(); continue; }client.alive = false;client.ws.ping();}}, 30_000);
Do and don't
Do
Validate and cap every message
Parse JSON inside try/catch, enforce a schema, set maxPayload, and rate-limit expensive messages. Treat every frame as untrusted input.
Heartbeat and reap dead sockets
Ping every 20–30 seconds and terminate clients that miss two cycles. Clean up room membership and timers in one close path.
Reconnect with jitter and intent
Exponential backoff prevents stampedes; permanent close codes prevent pointless loops. Restore subscriptions and resume cursors after reconnect.
Plan for stateful deployment
Share room state through Redis or another store, configure proxy idle timeouts, and drain sockets before shutdown.
Don't
Don't leave out authentication and authorization
A public WebSocket URL is an API. Authenticate the upgrade, check room access on every action, and reject messages from clients that never joined.
Don't send unbounded history or payloads
One 50 MB broadcast multiplied by 10,000 clients is an outage. Cap room size, message size, and backlog, then paginate over HTTP.
Don't ignore bufferedAmount or slow consumers
A slow socket accumulates queued sends in server memory. Backpressure must be observable and enforceable.
Don't reconnect after 1008 or logout
Blind retry loops amplify auth failures. Close deliberately and surface the reason.
Practice
Hands-on lab
Kill the socket and watch it recover
Use the workbench to inspect timing, send validated JSON, deliberately close with a code, and observe reconnect behavior with jitter.
- 1Connect and send a valid message. Inspect the echoed payload and round-trip time.
- 2Send invalid JSON and note how the server rejects it without killing the connection.
- 3Close with 1000, then trigger an abnormal disconnect with the server’s kill control.
- 4Turn on auto-reconnect and compare the backoff delays. Turn heartbeat off and watch the health state age.
Solo? This lab is designed for one participant and works without camera or microphone.
Quick quiz
Knowledge check
0 of 5 answered
- 01
What exactly changes when a WebSocket handshake succeeds?
- 02
Why is an application-level heartbeat useful when TCP already retransmits?
- 03
A client receives close code 1006. What should you do?
- 04
Two WebSocket instances behind a load balancer cannot see the same room. What is the most direct fix?
- 05
Which reconnection behavior is healthiest?
Key takeaways
- WebSocket is a framed, full-duplex protocol bootstrapped by an HTTP Upgrade — not a different internet.01
- Height, validation, rate limits, and a heartbeat are production requirements, not polish.02
- Rooms live on the server; scaling means sharing room state and fan-out across instances.03
- Close codes and backoff are part of your user experience. Design them deliberately.04
Common errors and fixes
Terms in this lesson
Done with this lesson?
Mark it complete, then keep the thread.