Choosing Heartbeat Intervals for SSE Permalink to this section

Part of HTTP Keep-Alive & Connection Lifecycle, under Backend Stream Generation & Connection Management.

A heartbeat — a comment line such as : hb written when a stream has been quiet — does three jobs on a Server-Sent Events connection. It stops idle timeouts in proxies and load balancers from closing the stream, it lets the server discover clients that vanished without closing their connection, and it lets the client detect a dead stream with a watchdog. Each job suggests a different interval. This guide derives one interval that satisfies all three, from the actual timeouts in your path.

Symptom & Developer Intent Permalink to this section

  • Quiet streams reconnect every 60 seconds (or 30, or 100), exactly matching some component’s idle timeout.
  • The server’s connection count includes thousands of clients that left hours ago.
  • The client watchdog reconnects healthy streams on slow mobile networks.
  • Heartbeats account for a noticeable share of bandwidth on a service with many idle viewers.
  • Nobody knows why the interval is the value it is.

The intent is an interval chosen from measured constraints, documented, and paired with a client watchdog that uses it correctly.

Root Cause Analysis Permalink to this section

Every stateful hop between browser and server has an idle timeout. The heartbeat must be shorter than the smallest of them, with margin for scheduling jitter and a delayed write.

Typical idle timeouts along an SSE path Bar chart of default idle timeouts for common components between browser and server, with the smallest one setting the upper bound for the heartbeat interval. Typical idle timeouts along an SSE path nginx proxy_read_timeout 60 s AWS ALB idle timeout 60 s Corporate proxies (common) ~30 s or less Azure Front Door / App GW ~240 s Some mobile carrier NATs as low as ~30 s default idle timeout in seconds (check your own configuration)
Defaults vary by product and configuration; measure yours. The smallest timeout anywhere in the path sets the ceiling for the heartbeat.

Dead-peer detection pulls the interval down further. A server only learns that a client vanished when a write fails, and a write to a dead peer typically fails only after TCP retransmissions time out following the write. So the time to detect a vanished client is roughly heartbeat interval plus retransmission time — the interval directly bounds how long ghost connections linger.

The client watchdog pulls in the other direction. If the watchdog reconnects after silence of one interval, a single delayed heartbeat on a congested network triggers a needless reconnect. A watchdog should wait about two intervals plus a margin.

Step-by-Step Resolution Permalink to this section

Step 1 — Measure the idle timeouts in your path Permalink to this section

# Hold a stream with heartbeats disabled and time how long it survives through each hop.
start=$(date +%s)
curl -sN https://app.example.com/debug/silent-stream > /dev/null
echo "closed after $(( $(date +%s) - start )) s"

Run it from the networks your users are on — office, home broadband, mobile — because carrier NAT and corporate proxy timeouts only show up from those networks.

Step 2 — Take the smallest timeout and divide by two or more Permalink to this section

heartbeat ≤ min(idle timeouts) / 2      e.g. min = 30 s  →  heartbeat ≤ 15 s

Halving gives margin for event-loop delays, garbage-collection pauses and the heartbeat timer’s own jitter. Fifteen seconds is a widely used value because it clears 30-second timeouts with margin.

Step 3 — Send heartbeats only when the stream is idle Permalink to this section

// Reset the heartbeat timer on every real write; send a comment only after true silence.
function withHeartbeat(res, intervalMs = 15000) {
  let timer;
  const arm = () => { clearTimeout(timer); timer = setTimeout(beat, intervalMs); };
  const beat = () => { res.write(': hb\n\n'); arm(); };
  const write = (chunk) => { res.write(chunk); arm(); };
  arm();
  return { write, stop: () => clearTimeout(timer) };
}

On busy streams, no heartbeat is ever sent, so they cost nothing. On idle streams, a comment of six bytes (plus framing) every 15 seconds is well under 1 byte per second per connection.

Heartbeats only fill silence Timeline of two minutes on one stream showing real events in a busy period with no heartbeats, followed by a quiet period filled with heartbeats every fifteen seconds. Heartbeats only fill silence Real events Heartbeats busy: no heartbeats 0 24 48 72 96 120 seconds stream goes quiet
The timer resets on every real event, so heartbeats appear only during quiet periods and never add load to busy streams.

Step 4 — Tell the client the interval and set the watchdog from it Permalink to this section

Send the interval once at the start of the stream so client and server cannot drift apart:

retry: 3000
event: config
data: {"heartbeatMs":15000}
let watchdog;
const HB_DEFAULT = 15000;
let hbMs = HB_DEFAULT;

function armWatchdog() {
  clearTimeout(watchdog);
  watchdog = setTimeout(() => { es.close(); reconnect(); }, hbMs * 2 + 5000);   // two missed beats + margin
}
es.addEventListener('config', (e) => { hbMs = JSON.parse(e.data).heartbeatMs; armWatchdog(); });
es.onmessage = armWatchdog;
// Comments are not dispatched by EventSource; use a named event or data frame if the client
// must observe heartbeats, or accept that the watchdog resets on real events only.

That last comment matters. EventSource swallows comment lines, so a pure-comment heartbeat keeps proxies happy and exposes dead peers to the server, but it is invisible to browser JavaScript. If the client needs a watchdog, send the heartbeat as a tiny named event (event: hb\ndata: \n\n) instead, or use a fetch-based client that can see every byte.

Step 5 — Bound ghost connections on the server Permalink to this section

With a 15-second heartbeat, a vanished client is typically detected within a minute or two, depending on TCP retransmission settings. If your platform allows it, lower TCP’s user timeout (TCP_USER_TIMEOUT on Linux) so unacknowledged writes fail after 30 seconds rather than minutes.

Validation & Monitoring Permalink to this section

Heartbeat interval trade-offs Matrix comparing heartbeat intervals of 5, 15, 30 and 60 seconds on idle-timeout safety, dead-peer detection and bandwidth. Heartbeat interval trade-offs Interval Survives 30 s timeouts Ghost detection Idle bandwidth 5 s yes fast 3× the 15 s cost 15 s yes ~1 min low 30 s borderline ~2 min very low 60 s no minutes minimal
Fifteen seconds is the usual sweet spot. Go lower only if a measured timeout in your path requires it.

Monitor reconnects per stream-hour by client network type. A spike at a round number of seconds after connect identifies a timeout the heartbeat does not beat. Monitor open streams against active users; a growing gap is ghost connections that detection is not catching.

Production Checklist Permalink to this section

Frequently Asked Questions Permalink to this section

Is a comment line or an event better as a heartbeat?

A comment line is invisible to EventSource listeners, which is ideal when only proxies and the server care. If client code needs to observe liveness, send a tiny named event instead.

Why not send heartbeats every second to be safe?

Across many idle connections that multiplies wake-ups, writes and bandwidth for no benefit once every timeout is already beaten. Choose the largest interval that is safely under your smallest timeout.

Do HTTP/2 PING frames replace SSE heartbeats?

They keep the HTTP/2 connection alive at hops that understand HTTP/2, but they are not visible to your application and do not cross every proxy. Application-level heartbeats work end to end.

Should the heartbeat carry data such as server time?

It can. Including a server timestamp lets clients compute clock offset for time displays, at a few extra bytes per beat — a good use of a message that is sent anyway.