Keeping Server-Sent Events Connections Alive
A heartbeat proves the connection is alive, not that anything is subscribed to it. Heartbeats, stale detection, reconnect backoff and teardown for long-lived Server-Sent Events.

Part 2 of a series on real-time notifications with Server-Sent Events.
With one tab holding the connection, the next thing to get right was keeping that connection alive, and knowing when it wasn't. A Server-Sent Events stream can stay open for hours, and it can fail in ways that look perfectly healthy from both ends.
Why a stream needs a heartbeat
A stream with nothing to send looks idle to everything between the browser and your server, and idle connections get closed. nginx's default proxy_read_timeout is 60 seconds, and so is an AWS load balancer's idle timeout. A connection can also drop without a clean close - a laptop switching networks, say - and neither end may notice for a long time.
A heartbeat handles both. Regular traffic keeps proxies from timing the stream out, and when the heartbeats stop arriving, the client knows the connection is gone.
A heartbeat proves the connection, not the subscription
A common way to run SSE on the server is a map of subscribers. When an event happens for a user, the server looks them up and writes to their open streams. So the map doesn't fill up with streams that died without saying goodbye, a sweep removes subscribers that haven't been active for a while.
The trap is in what counts as active. If a subscriber is only marked active when something is published to it, a user who receives nothing for a few minutes looks idle and gets swept out of the map. The stream itself is still open, though, and the heartbeat - a timer that belongs to the connection, not the subscription - keeps firing. The client sees a steady heartbeat and assumes all is well. The next real event finds nobody in the map and goes nowhere.
I ran into exactly this one, and nothing flagged it: the heartbeat and the client's watchdog both said the stream was fine.
You want both of these fixes:
- Refresh the subscription from the heartbeat. One timer keeps both alive, so they can't drift apart.
- Close the stream when you remove a subscription. A client can only reconnect if it can tell something is wrong. An open stream with nothing behind it hides the problem; a closed one makes the client reconnect on its own.
Telling the client the stream is real
Two named events make the client's job easier:
connected, sent the moment the server has finished subscribing. The browser's ownopenevent only means the HTTP response has started.connectedcomes from your code once the subscription exists, so it's the first proof that events can actually reach you.heartbeat, every 15 seconds, carrying a timestamp.
On the client, the tab holding the connection checks every 15 seconds whether it has seen either of those, or a real event, in the last 45. Three missed heartbeats and it treats the stream as dead. Checking again whenever the tab becomes visible means a laptop waking from sleep notices straight away rather than on the next tick.
let lastSeen = Date.now();
for (const type of ['connected', 'heartbeat', 'notification']) {
source.addEventListener(type, () => { lastSeen = Date.now(); });
}
const check = () => { if (Date.now() - lastSeen > 45_000) reconnect(); };
setInterval(check, 15_000);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') check();
});
Allowing three missed heartbeats leaves room for a slow network or a late timer on the server, so one delayed heartbeat doesn't tear down a healthy stream.
Reconnecting with backoff
EventSource reconnects on its own, but after a fixed delay - the server can set it with a retry: line - and with no backoff. It also gives up for good if the server answers with an error status or the wrong content type. Closing it on any error and reconnecting yourself gives you backoff, and a say in when to stop:
const base = Math.min(1000 * 2 ** failures, 30_000); // 2s, 4s, 8s, 16s, 30s
const delay = base * (0.5 + Math.random() / 2); // jitter
Reset the failure count when a stream opens, and give up after a few failures in a row - five here - rather than retrying forever against a server that's down.
Add the jitter too - it matters more than it looks. When a server restarts, every client fails at the same moment, and without randomness they all retry on the same schedule and arrive in waves.
With the election from part 1, a failing leader should also hand off before it backs off, so another tab can take over within a second while it waits.
Cleaning up on the server
A stream has to clean up in two cases: the request is aborted, or the stream's consumer cancels. A connection dropped by a proxy can trigger one without the other, so listen for both, and make whichever arrives second do nothing.
function sseResponse(signal, subscribe) {
let done = false, stop = () => {};
const stream = new ReadableStream({
start(ctrl) {
const enc = new TextEncoder();
const send = (event, data) =>
ctrl.enqueue(enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
const sub = subscribe(send);
send('connected', { t: Date.now() });
const beat = setInterval(() => {
sub.touch(); // keep the subscription alive, not just the connection
send('heartbeat', { t: Date.now() });
}, 15_000);
stop = (close = true) => {
if (done) return;
done = true;
clearInterval(beat);
sub.unsubscribe();
if (close) ctrl.close();
};
signal.addEventListener('abort', () => stop());
},
cancel() { stop(false); },
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'X-Accel-Buffering': 'no',
},
});
}
The last two headers stop proxies buffering the stream. nginx reads X-Accel-Buffering, and no-transform tells compression middleware to leave the body alone. Without them, events can sit in a buffer until it fills.
Edge cases worth handling
- Comments for proxies, events for the client. A line that starts with a colon is an SSE comment. It keeps the connection busy for proxies, but
EventSourceignores it, so the client never sees it and it can't feed a watchdog - that's what the namedheartbeatevent is for. What doesn't work isdata: :ping: that's a real message event with:pingas its data, which every listener then has to filter out. - Return 401 for an unauthenticated stream, not a redirect.
EventSourcefollows redirects, so a redirect to a login page has it fetch HTML it can't use, and your reconnect logic keeps sending it back. - Running more than one server process. An in-memory subscriber map only works within one process. With two, a user connected to one won't receive events published from the other, and you need something shared between them, such as Redis pub/sub.
Next: why the stream still needs a plain poll running underneath it.
