Heartbeats and Disconnects

Dead clients do not say goodbye, so send a heartbeat, sweep the ones that stop answering, and clean up on close.

Syntaxconst beat = setInterval(() => res.write(': ping\n\n'), 15000); res.on('close', () => clearInterval(beat));

A client that vanishes does not always say goodbye. When a laptop lid closes or a phone loses signal, no packet arrives to end the connection. Your process still holds the open response or socket, and its entry in your client list, indefinitely. Writes to it succeed into nothing.

A heartbeat is a small message sent on a timer to prove the link still works. On an SSE feed it is a comment line: a line starting with :, which clients ignore, written every fifteen seconds or so. On a WebSocket it is a protocol-level ping, ws.ping(), which the other end answers with a pong that arrives as the 'pong' event.

The sweep

Mark a client alive whenever its pong arrives. On an interval, close the ones that did not answer since the previous pass, then ping the rest. Use ws.terminate() for the dead ones: ws.close() starts a polite closing handshake and waits for a reply a vanished client will never send.

Clean up in one place

res.on('close') for SSE and ws.on('close') for WebSockets fire whichever side ended the connection, including a network failure. Remove the client from your list and clear its timer there, and nowhere else.

What trips people up

An interval you forget to clear keeps writing to a dead response, and a pending timer on its own keeps the process alive, so the service will not exit. socket.setTimeout(ms) closes nothing — it emits 'timeout' and you must call socket.end() or socket.destroy() yourself. Browser EventSource reconnects by itself, so expect your cleanup path to run often and keep it cheap.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Stop a client list growing forever as mobile users move in and out of signal.
  • Keep an idle SSE feed alive through a proxy that cuts quiet connections.
  • Detect within a minute that a browser tab is no longer receiving.
  • Remove a user from a presence list when their connection dies silently.

More examples

An SSE feed that heartbeats and cleans up after itself

Every long-lived connection needs this pairing: whatever you create on connect is undone in the close handler. Without the heartbeat, an idle feed is often cut after about a minute by something in the middle.

Example · javascript
const feeds = new Set();

function subscribe(req, res) {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
  });
  feeds.add(res);

  // Idle connections get dropped by proxies and mobile networks. A comment
  // line every 15 seconds keeps this one counted as active.
  const beat = setInterval(() => res.write(': ping\n\n'), 15000);
  beat.unref(); // this timer alone must not keep the process running

  // One place that undoes everything the subscribe did.
  res.on('close', () => {
    clearInterval(beat);
    feeds.delete(res);
  });
}

Ping, pong, and a sweep that removes the dead

The ping/pong sweep is how you find clients that stopped existing without telling you. One timer for the whole server, rather than one per client, keeps the cost flat as connections grow.

Example · javascript
const { WebSocketServer } = require('ws');
const wss = new WebSocketServer({ port: 8080 });

wss.on('connection', (ws) => {
  ws.isAlive = true;                           // our own flag on the socket
  ws.on('pong', () => { ws.isAlive = true; }); // sent in reply to ws.ping()
});

const sweep = setInterval(() => {
  for (const ws of wss.clients) {
    if (!ws.isAlive) {
      // terminate closes now; close() would wait for a reply that is not coming.
      ws.terminate();
      continue;
    }
    ws.isAlive = false; // the next pong turns it back on
    ws.ping();
  }
}, 30000);

wss.on('close', () => clearInterval(sweep)); // the server stopped; so does the sweep

Why it closed, and an idle timeout

Close codes tell you whether users are leaving or connections are breaking: a wave of 1006 is a network or proxy problem, not user behaviour. The idle timeout is the backstop for a client that answers no pings and sends no bytes.

Example · javascript
ws.on('close', (code, reason) => {
  // 1000 normal, 1001 going away (tab or app closed),
  // 1006 the connection died with no closing frame at all.
  console.log('closed', code, reason.toString() || '(no reason given)');
  presence.delete(userId);
});

// An idle timeout for a connection that should never go quiet.
// setTimeout here only reports: it does not close the socket for you.
req.socket.setTimeout(120000);
req.socket.on('timeout', () => {
  console.log('no traffic for two minutes');
  req.socket.destroy();
});

Discussion

  • Be the first to comment on this lesson.