Heartbeats and Disconnects
Dead clients do not say goodbye, so send a heartbeat, sweep the ones that stop answering, and clean up on close.
const 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
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.
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.
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 sweepWhy 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.
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