WebSockets Cheatsheet
Heartbeat and Reconnection
Use this WebSockets reference while you build software engineering projects, review code for technical interview prep, or polish examples for a software engineer resume.
Why Heartbeats Are Needed
TCP connections are stateful but "invisible" at the application layer — a dead peer or silent network drop is only discovered when a new write fails. Heartbeats detect dead connections before that write attempt.
| Without heartbeat | With heartbeat |
|---|---|
Dead client stays in wss.clients forever | Detected within 1–2 intervals |
| Server resources leak | Resources freed promptly |
| Client app thinks it's connected | Client reconnects automatically |
Ping/Pong — Server-Side Heartbeat (ws)
const HEARTBEAT_INTERVAL = 30_000; // 30 s const HEARTBEAT_TIMEOUT = 10_000; // 10 s to respond wss.on("connection", (ws) => { ws.isAlive = true; ws.on("pong", () => { ws.isAlive = true; // reset flag on pong receipt }); }); const heartbeatTimer = setInterval(() => { for (const ws of wss.clients) { if (!ws.isAlive) { ws.terminate(); // no pong received — kill it continue; } ws.isAlive = false; // will be reset when pong arrives ws.ping(); // send WebSocket ping frame } }, HEARTBEAT_INTERVAL); wss.on("close", () => clearInterval(heartbeatTimer));
ws.ping()sends a WebSocket protocol Ping frame (opcode0x9). The browser andwslibrary auto-reply with a Pong frame (opcode0xA).
Application-Level Ping (browser-compatible)
Some environments block WebSocket ping frames (e.g. certain CDNs). Use a JSON message instead.
// Server wss.on("connection", (ws) => { ws.isAlive = true; ws.on("message", (raw) => { const msg = JSON.parse(raw.toString()); if (msg.type === "pong") { ws.isAlive = true; return; } // ... handle other messages }); }); const heartbeat = setInterval(() => { for (const ws of wss.clients) { if (!ws.isAlive) { ws.terminate(); continue; } ws.isAlive = false; ws.send(JSON.stringify({ type: "ping", ts: Date.now() })); } }, 30_000);
// Client ws.addEventListener("message", ({ data }) => { const msg = JSON.parse(data); if (msg.type === "ping") { ws.send(JSON.stringify({ type: "pong", ts: msg.ts })); return; } // ... handle other messages });
Client-Initiated Heartbeat
// Client keeps connection alive when server does not send pings let pingTimer; ws.addEventListener("open", () => { pingTimer = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "ping" })); } }, 25_000); }); ws.addEventListener("close", () => clearInterval(pingTimer));
Reconnection — Exponential Backoff
class ReconnectingWebSocket { constructor(url, options = {}) { this.url = url; this.protocols = options.protocols; this.minDelay = options.minDelay ?? 1_000; this.maxDelay = options.maxDelay ?? 30_000; this.maxRetries = options.maxRetries ?? Infinity; this.delay = this.minDelay; this.retries = 0; this.ws = null; this.shouldReconnect = true; this.connect(); } connect() { this.ws = new WebSocket(this.url, this.protocols); this.ws.addEventListener("open", () => { this.delay = this.minDelay; // reset on success this.retries = 0; this.onopen?.(); }); this.ws.addEventListener("message", (e) => this.onmessage?.(e)); this.ws.addEventListener("error", (e) => this.onerror?.(e)); this.ws.addEventListener("close", (e) => { this.onclose?.(e); if (this.shouldReconnect && this.retries < this.maxRetries) { const jitter = Math.random() * 1000; setTimeout(() => this.connect(), this.delay + jitter); this.delay = Math.min(this.delay * 2, this.maxDelay); this.retries++; } }); } send(data) { if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(data); } close(code = 1000, reason = "") { this.shouldReconnect = false; this.ws?.close(code, reason); } // wire up handlers onopen = null; onmessage = null; onerror = null; onclose = null; }
Usage:
const ws = new ReconnectingWebSocket("wss://api.example.com/ws"); ws.onopen = () => console.log("connected"); ws.onmessage = (e) => console.log("msg:", e.data); ws.onclose = (e) => console.log("closed:", e.code); ws.send(JSON.stringify({ type: "hello" }));
Reconnection Backoff Reference
| Attempt | Delay (base 1 s, ×2 each) | With jitter (0–1 s) |
|---|---|---|
| 1 | 1 s | 1.0–2.0 s |
| 2 | 2 s | 2.0–3.0 s |
| 3 | 4 s | 4.0–5.0 s |
| 4 | 8 s | 8.0–9.0 s |
| 5 | 16 s | 16.0–17.0 s |
| 6+ | 30 s (capped) | 30.0–31.0 s |
Detecting Network Offline/Online
Uses the ReconnectingWebSocket class above — a raw browser WebSocket has no connect() method; you must construct a new instance to reconnect.
const rws = new ReconnectingWebSocket("wss://api.example.com/ws"); window.addEventListener("offline", () => { // Stop the retry loop while offline. Browser close() only accepts // code 1000 or 3000-4999 — passing 1001 throws. rws.close(4000, "Offline"); }); window.addEventListener("online", () => { rws.shouldReconnect = true; if (!rws.ws || rws.ws.readyState >= WebSocket.CLOSING) rws.connect(); });
onlineonly means a network interface came up, not that the server is reachable — keep the backoff loop as the source of truth and treat these events as hints.
Reconnection with Message Queue
class BufferedReconnectingWS extends ReconnectingWebSocket { #queue = []; send(data) { if (this.ws?.readyState === WebSocket.OPEN) { // drain queue first while (this.#queue.length) super.send(this.#queue.shift()); super.send(data); } else { this.#queue.push(data); // buffer while disconnected } } onopen = () => { while (this.#queue.length) super.send(this.#queue.shift()); }; }
Server-Side Dead Connection Cleanup
// Full pattern combining heartbeat + cleanup function startHeartbeat(wss, interval = 30_000) { const timer = setInterval(() => { const before = wss.clients.size; for (const ws of wss.clients) { if (ws.isAlive === false) { ws.terminate(); continue; } ws.isAlive = false; ws.ping(null, false, (err) => { if (err) ws.terminate(); // ping failed — socket already dead }); } const after = wss.clients.size; if (before !== after) console.log(`Cleaned ${before - after} dead sockets`); }, interval); wss.on("close", () => clearInterval(timer)); wss.on("connection", (ws) => { ws.isAlive = true; ws.on("pong", () => { ws.isAlive = true; }); }); return timer; }
Gotchas
ws.ping()with no callback silently swallows errors — pass a callback to catch broken pipes.- Don't call
ws.terminate()insidews.on("pong")— terminate it in the heartbeat interval, not on pong receipt. - Exponential backoff with jitter is critical to prevent thundering-herd reconnection storms after a server restart.
CLOSEDreadyState (3) means you must create a newWebSocketinstance — you cannot reuse a closed one.- On mobile devices, the connection may drop silently when the screen locks; always reconnect on
onlineevent and on pagevisibilitychangetovisible. - Set
pingTimeoutconservatively — too short causes unnecessary reconnects on slow networks.