07 · WebSockets¶
HTTP is request/response: the client asks, the server answers. For chat, live dashboards, multiplayer games, collaborative editing, or notifications, the server needs to push data whenever something happens. WebSockets provide a long-lived, full-duplex connection over which either side can send messages at any time.
Alternatives worth knowing:
- Server-Sent Events (SSE) — a plain HTTP response that stays open and streams
data:lines. One-way (server → client), works through most proxies, auto-reconnects in browsers. Often enough for notifications and dashboards. - Long polling — the client makes a request the server holds until there's news. A fallback, rarely the first choice today.
- Socket.IO — a library on top of WebSockets adding rooms, acknowledgements, reconnection, and fallbacks. It uses its own protocol, so its clients and servers only talk to each other.
This lesson uses the ws package on the server — small, fast, and widely used — and
the WebSocket client that current Node releases provide as a global, with the same API
as in browsers.
A broadcast server¶
import { createServer } from 'node:http';
import { WebSocketServer } from 'ws';
const server = createServer((req, res) => res.end('ok'));
const wss = new WebSocketServer({ server, path: '/ws', maxPayload: 64 * 1024 });
wss.on('connection', (socket, req) => {
socket.isAlive = true;
socket.on('pong', () => { socket.isAlive = true; });
socket.on('error', (err) => console.error('socket error', err.message));
socket.on('message', (data, isBinary) => {
if (isBinary) return socket.close(1003, 'text only');
let msg;
try { msg = JSON.parse(data.toString()); } catch { return socket.close(1007, 'invalid JSON'); }
// Broadcast to every open client (including the sender)
const out = JSON.stringify({ from: req.socket.remotePort, text: String(msg.text).slice(0, 500) });
for (const client of wss.clients) {
if (client.readyState === client.OPEN) client.send(out);
}
});
socket.send(JSON.stringify({ system: `welcome, ${wss.clients.size} connected` }));
});
// Heartbeat: terminate connections that stop answering pings (dead NAT paths, sleeping laptops)
const interval = setInterval(() => {
for (const socket of wss.clients) {
if (!socket.isAlive) { socket.terminate(); continue; }
socket.isAlive = false;
socket.ping();
}
}, 30_000);
wss.on('close', () => clearInterval(interval));
server.listen(3300, () => console.log('ws://localhost:3300/ws'));
And a client script using the global WebSocket:
// Uses the WebSocket client built into current Node releases (same API as browsers)
function connect(name) {
return new Promise((resolve) => {
const ws = new WebSocket('ws://localhost:3300/ws');
ws.addEventListener('message', (e) => console.log(`${name} got:`, e.data));
ws.addEventListener('open', () => resolve(ws));
});
}
const alice = await connect('alice');
const bob = await connect('bob');
alice.send(JSON.stringify({ text: 'hi bob' }));
setTimeout(() => { alice.close(); bob.close(); }, 200);
alice got: {"system":"welcome, 1 connected"}
bob got: {"system":"welcome, 2 connected"}
alice got: {"from":50660,"text":"hi bob"}
bob got: {"from":50660,"text":"hi bob"}
Details that matter in the server:
- Shared HTTP server. The WebSocket server attaches to a normal
http.Server, so one port serves both your REST API and/ws. With Express, pass the server returned byapp.listen(). maxPayloadcaps message size (thewsdefault is much larger); a client sending a 100 MB frame is rejected instead of buffered.- Validate every message. Treat incoming frames exactly like request bodies: parse
defensively, check the schema (Zod works fine here), limit lengths. Close codes like
1003(unsupported data) and1007(invalid payload) tell well-behaved clients why. - Heartbeats. A TCP connection can die silently — a phone switches networks, a NAT
entry times out — and neither side notices until it tries to send. Periodic pings with
a pong check find and clean up dead sockets; otherwise your
clientsset leaks. - Check
readyStatebefore sending, and add an'error'listener on each socket.
Authentication¶
Browsers can't set custom headers (like Authorization) on WebSocket connections. Common
approaches:
- Cookies: the upgrade request carries the site's cookies, so a session cookie
(Level 2, lesson 07) authenticates it. Also check the
Originheader to block other sites from opening sockets with your users' cookies (cross-site WebSocket hijacking). - A short-lived token in the query string (
/ws?token=...) or in the first message. Tokens in URLs may appear in logs, so keep them single-use and short-lived.
Authenticate during the upgrade, before accepting the connection:
const wss = new WebSocketServer({ noServer: true });
server.on('upgrade', async (req, socket, head) => {
try {
const user = await authenticateUpgrade(req); // throws if invalid
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req, user));
} catch {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
}
});
Backpressure on sockets¶
ws.send() queues data in memory if the network is slower than you send. For a slow
client receiving a busy feed, socket.bufferedAmount grows without limit. Check it and
drop messages or disconnect clients that fall too far behind:
Scaling beyond one process¶
wss.clients only contains sockets connected to this process. With several
processes (lesson 05) or containers, a message received by process A must also reach
users connected to process B. The usual solution is a pub/sub backbone: each process
publishes incoming messages to a Redis channel and subscribes to it, relaying what it
receives to its local clients. The Level 3 project implements the local part and
describes this extension.
How It Actually Works¶
A WebSocket connection starts as an HTTP/1.1 request:
GET /ws HTTP/1.1
Host: localhost:3300
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
The server answers 101 Switching Protocols with Sec-WebSocket-Accept, computed as
base64(SHA-1(key + a fixed GUID defined in RFC 6455)). That proves the server understood
the WebSocket request (it's not an anti-forgery measure). From then on, the same TCP
socket carries frames instead of HTTP.
In Node, the HTTP server emits an 'upgrade' event instead of 'request' for such
requests, handing over the raw net.Socket. ws validates the headers, writes the 101
response, and then parses frames from the socket's byte stream: each frame has a small
header with an opcode (text, binary, close, ping, pong, continuation), a FIN bit
(fragmentation), a payload length (7 bits, or extended to 16 or 64 bits), and — for frames
from clients — a 4-byte mask XORed over the payload. Masking prevents a malicious page
from crafting bytes that confuse intermediary caches. Pings and pongs are control frames
handled by the library.
Each connection is just an open socket in libuv's poller, so an idle WebSocket costs
memory (buffers, your per-connection state) but no CPU. A single Node process can hold a
very large number of mostly idle connections; the limits you hit first are usually file
descriptors (ulimit -n), memory, and the CPU cost of broadcasting to many clients.
Common mistakes¶
- No heartbeat → dead connections accumulate.
- Trusting message content — no validation, no size limits.
- Missing
Origincheck with cookie-based auth. - Broadcasting with
JSON.stringifyinside the loop — serialize once, send the same string to everyone. - Ignoring
bufferedAmountfor slow consumers. - Assuming in-memory client lists are global when running multiple processes.
Exercise¶
- Add rooms: messages
{ "type": "join", "room": "general" }and{ "type": "say", "room": "general", "text": "..." }; only room members receivesay. - Validate messages with a Zod discriminated union on
type, closing with 1007 on invalid input. - Implement
/eventsas Server-Sent Events that pushes the current time every second, and consume it withcurl -N. Compare the code with the WebSocket version. - Write a load script that opens 2,000 WebSocket clients using the global
WebSocketand measure the server's memory withprocess.memoryUsage()before and after.