WebSockets Cheatsheet
Heartbeat and Reconnection
Use this WebSockets reference while you build software engineering projects, review code, or refresh the syntax you reach for most.
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.