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 heartbeatWith heartbeat
Dead client stays in wss.clients foreverDetected within 1–2 intervals
Server resources leakResources freed promptly
Client app thinks it's connectedClient 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 (opcode 0x9). The browser and ws library auto-reply with a Pong frame (opcode 0xA).

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

AttemptDelay (base 1 s, ×2 each)With jitter (0–1 s)
11 s1.0–2.0 s
22 s2.0–3.0 s
34 s4.0–5.0 s
48 s8.0–9.0 s
516 s16.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();
});

online only 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() inside ws.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.
  • CLOSED readyState (3) means you must create a new WebSocket instance — you cannot reuse a closed one.
  • On mobile devices, the connection may drop silently when the screen locks; always reconnect on online event and on page visibilitychange to visible.
  • Set pingTimeout conservatively — too short causes unnecessary reconnects on slow networks.