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):
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:
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:
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.