Heartbeats and Reconnection

Detecting half-open connections with ping/pong, and a complete client with exponential backoff reconnection.

"The connection looks open" isn't good enough

A WebSocket connection can go half-open: one side believes it's still connected while the other end is actually gone — the process crashed, a NAT mapping silently expired, a laptop went to sleep, or a mobile device switched from WiFi to cellular mid-session. Neither TCP nor the WebSocket protocol guarantees that a clean close event fires in every one of these situations, and the underlying OS-level TCP timeout that would eventually notice can take several minutes — far too slow for anything that wants to feel responsive.

The fix is the same one every long-lived connection protocol reaches for: a periodic heartbeat, proving liveness far faster than waiting on the network stack to notice on its own.

Protocol-level ping/pong (server-side, with ws)

The WebSocket protocol itself defines control frames for exactly this — Ping and Pong — distinct from ordinary text/binary message frames. Node's ws library exposes them directly, which makes this the natural mechanism between a server and any non-browser client (including server-to-server WebSocket connections):

Javascript
import { WebSocketServer } from 'ws';

const wss = new WebSocketServer({ port: 8080 });

function markAlive() {
  this.isAlive = true;
}

wss.on('connection', (ws) => {
  ws.isAlive = true;
  ws.on('pong', markAlive); // the client's WS implementation replies to a ping automatically
});

// Every 30 seconds: ping everyone, and terminate anyone who didn't pong last time
const interval = setInterval(() => {
  for (const ws of wss.clients) {
    if (ws.isAlive === false) {
      ws.terminate(); // no pong since the last check — treat as dead, don't wait any longer
      continue;
    }
    ws.isAlive = false;
    ws.ping();
  }
}, 30000);

wss.on('close', () => clearInterval(interval));

The isAlive flag is the trick: it's set to false right before sending a ping, and only flipped back to true when the corresponding pong arrives. Any connection still showing false on the next round has missed a full cycle without responding — a strong signal it's dead, well before a TCP-level timeout would ever notice.

Worth knowing: browsers do not expose an API for JavaScript to send or receive protocol-level ping/pong frames directly — the browser and OS handle protocol pings automatically under the hood, but there's no socket.ping() in the standard browser WebSocket object. For a browser client, an application-level heartbeat is the portable approach instead.

Application-level heartbeat (for browser clients)

Since browser JavaScript can't originate protocol-level pings, the common workaround is a plain heartbeat message sent over the same channel as ordinary application messages, using whatever message envelope the app already agreed on:

Javascript
let lastPongAt = Date.now();

const heartbeatTimer = setInterval(() => {
  if (Date.now() - lastPongAt > 45000) {
    console.warn('No pong in 45s — treating the connection as dead');
    socket.close(); // triggers onclose, which drives the reconnect logic below
    return;
  }
  socket.send(JSON.stringify({ type: 'ping' }));
}, 15000);

socket.onmessage = (event) => {
  const message = JSON.parse(event.data);
  if (message.type === 'pong') {
    lastPongAt = Date.now();
    return;
  }
  handleApplicationMessage(message);
};

The server simply replies to every { type: 'ping' } it receives with { type: 'pong' }. This is functionally the same idea as protocol-level ping/pong, just carried as ordinary application messages instead of WebSocket control frames — necessary specifically because the browser side of the conversation has no lower-level alternative.

Reconnecting with exponential backoff

Detecting a dead connection is only half the problem — a production client also needs to get itself reconnected without making a bad situation worse. A naive onclose handler that immediately opens a new WebSocket turns a brief server restart into a self-inflicted pile-up: every disconnected client reconnects in the same instant, right as the server is trying to recover.

A complete reconnecting client, combining backoff, jitter, and the heartbeat above:

Javascript
class ReconnectingSocket {
  constructor(url) {
    this.url = url;
    this.attempt = 0;
    this.connect();
  }

  connect() {
    this.socket = new WebSocket(this.url);

    this.socket.onopen = () => {
      console.log('Connected');
      this.attempt = 0; // reset backoff after a successful connection
    };

    this.socket.onmessage = (event) => this.handleMessage(event);

    this.socket.onclose = () => {
      const delay = Math.min(1000 * 2 ** this.attempt, 30000) + Math.random() * 1000;
      this.attempt++;
      console.log(`Disconnected — reconnecting in ${Math.round(delay)}ms`);
      setTimeout(() => this.connect(), delay);
    };
  }

  handleMessage(event) {
    const message = JSON.parse(event.data);
    if (message.type === 'pong') return;
    this.onMessage?.(message);
  }

  send(data) {
    if (this.socket.readyState === WebSocket.OPEN) {
      this.socket.send(JSON.stringify(data));
    }
  }
}

const client = new ReconnectingSocket('wss://example.com/chat');
client.onMessage = (message) => console.log('Received:', message);

Resetting this.attempt back to 0 inside onopen matters: without it, a client that reconnects successfully once but drops again later keeps using an ever-growing delay computed from the very first disconnect, rather than treating the new failure as a fresh backoff sequence.

Common mistakes

  • Assuming a WebSocket that "looks open" client-side genuinely has a live server on the other end — without a heartbeat, a half-open connection can go undetected for minutes.
  • Reconnecting immediately with no backoff, turning a brief server restart into a thundering herd of every client reconnecting at the same instant.
  • Forgetting to reset the backoff attempt counter after a successful reconnect, leaving a client that drops again later stuck on a delay computed from its very first disconnect.
  • Not resubscribing to rooms/channels or re-sending authentication after a reconnect — a freshly opened WebSocket connection starts with none of the previous connection's server-side state.