WebSockets & Real-Time

Node.js Advanced — WebSockets & Real-Time

Node.js Advanced

Chapter 3 of 8  ·  WebSockets & Real-Time

WebSockets & Real-Time

HTTP is half-duplex: the client asks, the server answers, and the connection closes. For real-time features — live notifications, chat, collaborative editing, live dashboards — that model breaks down. You either poll (wasteful and laggy) or you upgrade the connection to a full-duplex WebSocket: a persistent TCP tunnel where either side can push a frame at any time. Node's single-threaded, event-driven architecture is a natural fit for holding thousands of these long-lived connections simultaneously.

HTTP vs WebSocket vs SSE

MechanismDirectionConnectionBest forOverhead
HTTP polling Client → Server only New TCP per request Infrequent checks (every 30 s+) High (full HTTP headers each time)
SSE
(Server-Sent Events)
Server → Client only Persistent HTTP/1.1 stream Push feeds (news, notifications, progress) Low; auto-reconnect built in
WebSocket Full-duplex (both ways) Persistent TCP after upgrade Chat, games, collaborative tools, live data Very low (2–10 byte frame header)

The WebSocket Handshake

Client Server GET /ws HTTP/1.1 Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== ──────────────► Sec-WebSocket-Version: 13 HTTP/1.1 101 Switching Protocols Upgrade: websocket ◄── Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTx... Connection is now a raw TCP tunnel — HTTP is done ◄────────────────── ws frame ──────────────────► (either side, any time) ◄────────────────── ws frame ──────────────────►

The ws Library — Server Basics

// npm install ws const http = require('node:http'); const { WebSocketServer } = require('ws'); const server = http.createServer(); // share one port with HTTP routes const wss = new WebSocketServer({ server }); wss.on('connection', (ws, req) => { const ip = req.socket.remoteAddress; console.log(`Client connected: ${ip}`); // Receive a message from the client ws.on('message', (data, isBinary) => { const text = isBinary ? data : data.toString(); console.log(`Received: ${text}`); ws.send(`Echo: ${text}`); // send a reply }); ws.on('close', (code, reason) => { console.log(`Client disconnected: ${code} ${reason}`); }); ws.on('error', (err) => { console.error('Socket error:', err.message); }); ws.send('Welcome!'); // push immediately on connect }); server.listen(3000, () => console.log('Listening on :3000'));

Connection State — readyState

// ws.readyState — always check before sending const { CONNECTING, OPEN, CLOSING, CLOSED } = ws.constructor; // CONNECTING = 0 — handshake in progress (shouldn't happen server-side) // OPEN = 1 — ready to send/receive // CLOSING = 2 — close handshake started // CLOSED = 3 — connection gone function safeSend(ws, message) { if (ws.readyState === ws.constructor.OPEN) { ws.send(message); return true; } return false; // caller knows the send was skipped } // send() also accepts a callback for write errors ws.send(data, (err) => { if (err) console.error('Send failed:', err); });

Broadcasting to All Connected Clients

// wss.clients is a Set of every connected WebSocket function broadcast(wss, message) { for (const client of wss.clients) { if (client.readyState === client.constructor.OPEN) { client.send(message); } } } // Broadcast to everyone except the sender function broadcastExcept(wss, sender, message) { for (const client of wss.clients) { if (client !== sender && client.readyState === client.constructor.OPEN) { client.send(message); } } } // Usage in a chat server wss.on('connection', (ws) => { ws.on('message', (data) => { const msg = JSON.stringify({ type: 'chat', text: data.toString(), at: new Date().toISOString(), }); broadcastExcept(wss, ws, msg); // everyone else gets the message }); });

Storing Metadata on Connections

The ws library's WebSocket objects are plain objects — you can attach any properties you need to track per-connection state (username, rooms, authenticated status).

wss.on('connection', (ws, req) => { // Parse auth token from query string const url = new URL(req.url, 'http://localhost'); const token = url.searchParams.get('token'); const user = verifyToken(token); // returns user obj or null if (!user) { ws.close(4001, 'Unauthorized'); // 4000–4999 are app-defined codes return; } // Attach metadata directly to the ws object ws.userId = user.id; ws.username = user.name; ws.rooms = new Set(); ws.isAlive = true; // used by heartbeat (see below) console.log(`${ws.username} connected`); ws.send(JSON.stringify({ type: 'hello', userId: ws.userId })); });

Rooms — Scoped Broadcasting

// A room is just a name → Set<WebSocket> mapping const rooms = new Map(); // roomName → Set<ws> function joinRoom(ws, room) { if (!rooms.has(room)) rooms.set(room, new Set()); rooms.get(room).add(ws); ws.rooms.add(room); } function leaveRoom(ws, room) { rooms.get(room)?.delete(ws); ws.rooms.delete(room); if (rooms.get(room)?.size === 0) rooms.delete(room); // prune empty rooms } function broadcastToRoom(room, message, exclude = null) { const members = rooms.get(room); if (!members) return; for (const client of members) { if (client !== exclude && client.readyState === client.constructor.OPEN) { client.send(message); } } } // Clean up when a client disconnects ws.on('close', () => { for (const room of ws.rooms) leaveRoom(ws, room); }); // Message dispatch loop ws.on('message', (raw) => { let msg; try { msg = JSON.parse(raw); } catch { return; } if (msg.type === 'join') joinRoom(ws, msg.room); if (msg.type === 'leave') leaveRoom(ws, msg.room); if (msg.type === 'chat') broadcastToRoom(msg.room, raw, ws); });

Heartbeat — Detecting Dead Connections

TCP connections can die silently: the client's laptop closes the lid, a proxy drops the socket, the network blips. From the server's perspective the socket looks open but nothing will ever arrive. A ping/pong heartbeat detects these zombie connections and terminates them.

// ws sends a WebSocket-protocol ping; the browser auto-replies with a pong. // For Node.js ws clients, handle 'pong' manually. wss.on('connection', (ws) => { ws.isAlive = true; ws.on('pong', () => { ws.isAlive = true; }); // pong resets the flag }); // Every 30 s: ping everyone; if they didn't pong since last check → terminate const heartbeat = setInterval(() => { for (const ws of wss.clients) { if (!ws.isAlive) { ws.terminate(); // hard close — don't wait for close handshake continue; } ws.isAlive = false; // reset; if pong arrives it'll flip back to true ws.ping(); } }, 30_000); // Clean up the interval when the server closes wss.on('close', () => clearInterval(heartbeat));

Message Protocol — Typed JSON Envelopes

Raw strings over WebSocket become unmanageable fast. A simple typed envelope makes the server's message-dispatch loop clean and extensible.

// Every message: { type: string, payload: any } const handlers = { chat: (ws, payload) => broadcastToRoom(payload.room, encode('chat', payload), ws), join: (ws, payload) => joinRoom(ws, payload.room), leave: (ws, payload) => leaveRoom(ws, payload.room), ping: (ws) => send(ws, 'pong', {}), }; function encode(type, payload) { return JSON.stringify({ type, payload }); } ws.on('message', (raw) => { let parsed; try { parsed = JSON.parse(raw); } catch { ws.close(4000, 'Bad JSON'); return; } const { type, payload } = parsed; const handler = handlers[type]; if (!handler) { ws.close(4002, `Unknown type: ${type}`); return; } try { handler(ws, payload); } catch (err) { console.error(`Handler error [${type}]:`, err); ws.send(encode('error', { message: 'Internal error' })); } });

WebSocket Client in Node.js

// The 'ws' package works as a client too — useful for testing or service-to-service const { WebSocket } = require('ws'); const ws = new WebSocket('ws://localhost:3000?token=abc123'); ws.on('open', () => { console.log('Connected'); ws.send(JSON.stringify({ type: 'join', payload: { room: 'general' } })); }); ws.on('message', (data) => { const msg = JSON.parse(data); console.log('Received:', msg); }); ws.on('close', (code, reason) => { console.log(`Closed: ${code}${reason}`); });

ws.close() vs ws.terminate()

ws.close(code, reason) sends a WebSocket close frame — a clean handshake. The peer gets to send its own close frame and flush pending messages.

ws.terminate() destroys the underlying TCP socket immediately — no close frame, no handshake. Use it for zombie connections (heartbeat failures) or misbehaving clients.

Binary vs Text Frames

WebSocket frames are either text (UTF-8) or binary. ws.send(string) sends text; ws.send(Buffer) sends binary. The isBinary flag in the message event tells you which arrived.

For JSON APIs always use text. For audio/video/file payloads, binary avoids the base64 overhead of encoding binary data into a JSON string.

Scaling Beyond One Process

wss.clients only contains sockets on this process. If you cluster or run multiple instances, broadcast messages won't reach clients connected to other workers.

Solution: use a pub/sub bus (Redis Pub/Sub is the classic choice) — each worker subscribes to a channel and re-broadcasts locally to its own clients.

WebSocket Close Codes

1000 — Normal closure. 1001 — Going away (server restart). 1006 — Abnormal closure (connection lost without close frame). 1011 — Server error.

App-defined codes live in 4000–4999: 4001 Unauthorized, 4002 Bad Request, 4003 Rate limited — these are yours to define freely.

⚠️ Back-Pressure on ws.send()

If you send() faster than the client can consume, frames queue up in the kernel's TCP send buffer. When the buffer fills, the OS blocks writes — and in Node's non-blocking model this means pending writes pile up in memory on your side.

Check ws.bufferedAmount before sending high-frequency data (e.g. live chart ticks). If it's non-zero, you're sending faster than the client is draining — skip the frame or throttle the producer rather than letting the queue grow.

💡 ws vs socket.io

ws is a thin, spec-compliant WebSocket library — no extras, full control. You implement rooms, heartbeats, acknowledgements, and reconnection logic yourself (as shown above).

socket.io wraps ws and adds all of those features out of the box — plus a fallback to long-polling for environments that block WebSockets. The cost is a custom protocol: the client must use the socket.io client library; bare WebSocket clients can't talk to it directly.

For internal microservice communication or when you control both ends, ws is preferable. For public-facing apps where you want reconnection/rooms/acks without building them, socket.io saves time.

Coding Challenges

Challenge 1 — Echo Server with Heartbeat

Build a WebSocket server that: echoes every text message back with a timestamp prepended ([10:45:32] your message), runs a heartbeat every 10 seconds and terminates zombie connections, tracks total messages received (all clients combined) and broadcasts a { type: "stats", count: N } JSON frame to all clients every 30 seconds. Write a Node.js ws client that connects, sends 5 messages one second apart, logs every frame it receives, then disconnects cleanly.

View sample solution ↗

Challenge 2 — Multi-Room Chat Server

Build a chat server that supports joining and leaving named rooms, broadcasting messages within a room, and a { type: "who", room } query that returns the list of usernames in that room. On connect, extract the username query parameter and reject the connection (close code 4001) if it's missing or empty. On disconnect, broadcast a { type: "leave", username } frame to any room the user was in. Include a small test script that spins up 3 clients, puts them in two overlapping rooms, sends messages, and verifies the right clients receive them.

View sample solution ↗

Challenge 3 — Live Dashboard Feed

Build a server that generates fake "sensor readings" (a random number between 0–100 every 500 ms) and streams them only to subscribed clients. Clients subscribe by sending { type: "subscribe", sensor: "temp" } and unsubscribe with { type: "unsubscribe", sensor: "temp" }. A client should be able to subscribe to multiple sensors simultaneously. The server must check ws.bufferedAmount before each send and skip the frame (log a warning instead) if it is non-zero — demonstrating back-pressure awareness. Include a test that connects 5 clients with different subscription sets and verifies each receives only its subscribed sensors.

View sample solution ↗