Exponential Backoff with Jitter for SSE Reconnects Permalink to this section
Part of Error Handling & Reconnection UX, under Frontend Consumption & Client Patterns.
EventSource reconnects on a fixed interval, which is the right behaviour for a single client and the wrong behaviour for fifty thousand of them recovering from the same outage at the same moment.
Symptom & Developer Intent Permalink to this section
- A service comes back after a brief outage and is immediately knocked over by reconnects.
- The load balancer shows a reconnect spike of tens of thousands per second, then a trough.
- Recovery takes far longer than the original outage.
- A client that cannot connect retries every three seconds indefinitely, draining battery.
- Reconnect attempts arrive in visible waves, each aligned to the retry interval.
The intent is reconnection that backs off as failures continue, spreads clients over time, and returns to normal instantly once the service recovers.
Root Cause Analysis Permalink to this section
The browserβs algorithm is deliberately simple: on a transport error, wait the reconnection time β the last retry: value the server sent, or a browser default of a few seconds β then reconnect. It never lengthens the interval, and it applies no randomisation.
For one client that is ideal. For a population it produces synchronisation: every client disconnected by the same event waits the same interval and returns together, and if the server is still recovering they all fail and return together again. The load pattern is a square wave, and each pulse can be large enough to re-break a service that was almost healthy.
Two properties fix it. Exponential growth reduces the rate at which a persistently failing client retries, and jitter spreads a synchronised population across the interval. Jitter is the more important of the two: exponential backoff without randomisation still produces synchronised waves, just further apart.
The complication is that you cannot change the browserβs built-in behaviour. Controlling reconnection means taking it over: close the connection on error, wait your own interval, then construct a new EventSource.
Step-by-Step Resolution Permalink to this section
Step 1 β Decide whether you need this at all Permalink to this section
Server-controlled retry: plus a small server-side spread is enough for many deployments, and it requires no client code. See Setting the retry Interval in SSE Streams.
// Server: give each client a slightly different reconnect interval.
const base = 5000;
const jittered = Math.round(base * (0.8 + Math.random() * 0.4)); // 4β6 s
res.write(`retry: ${jittered}\n\n`);
Take over on the client only when you need growth across repeated failures, or a cap on total attempts.
Step 2 β Take control of the reconnect cycle Permalink to this section
// managed-stream.js β the browser retries once; we handle everything after that.
export function createManagedStream(url, handlers, opts = {}) {
const { baseMs = 1000, maxMs = 60_000, maxAttempts = Infinity } = opts;
let es = null;
let attempt = 0;
let timer = null;
let stopped = false;
const connect = () => {
es = new EventSource(url, { withCredentials: true });
es.onopen = () => {
attempt = 0; // success resets the ladder immediately
handlers.onOpen?.();
};
for (const [type, fn] of Object.entries(handlers.events ?? {})) {
es.addEventListener(type, fn);
}
es.onerror = () => {
es.close(); // take over: stop the browser's own retry
if (stopped) return;
if (++attempt > maxAttempts) return handlers.onGiveUp?.(attempt);
const wait = backoff(attempt, baseMs, maxMs);
handlers.onRetryScheduled?.({ attempt, wait });
timer = setTimeout(connect, wait);
};
};
connect();
return () => { stopped = true; clearTimeout(timer); es?.close(); };
}
Step 3 β Choose a jitter strategy deliberately Permalink to this section
// Full jitter: pick uniformly in [0, exponential cap]. Best spread, simplest to reason about.
function backoff(attempt, baseMs, maxMs) {
const cap = Math.min(maxMs, baseMs * 2 ** (attempt - 1));
return Math.random() * cap;
}
// Equal jitter: half fixed, half random. Keeps a floor so retries are not instant.
function backoffEqual(attempt, baseMs, maxMs) {
const cap = Math.min(maxMs, baseMs * 2 ** (attempt - 1));
return cap / 2 + Math.random() * (cap / 2);
}
// Decorrelated jitter: grows from the PREVIOUS delay, spreads well without a hard ladder.
function backoffDecorrelated(prev, baseMs, maxMs) {
return Math.min(maxMs, baseMs + Math.random() * (prev * 3 - baseMs));
}
Full jitter gives the flattest recovery curve and is the right default. Equal jitter is preferable when an immediate retry would be wasteful β an expensive handshake, for example.
Step 4 β Reset on success, not on the first byte Permalink to this section
es.onopen = () => { attempt = 0; }; // headers validated: the connection really works
Resetting on the first message instead means a stream that opens and immediately fails keeps the ladder short, and the population re-synchronises.
Step 5 β Cap the ladder and surface it in the UI Permalink to this section
const stop = createManagedStream('/api/stream', {
events: { update: onUpdate },
onOpen: () => setStatus('live'),
onRetryScheduled: ({ attempt, wait }) => {
// Stay quiet for the first couple of attempts β most reconnects finish invisibly.
if (attempt > 2) setStatus(`reconnecting in ${Math.round(wait / 1000)}s`);
},
onGiveUp: () => setStatus('offline'), // offer a manual retry button here
}, { baseMs: 1000, maxMs: 60_000, maxAttempts: 12 });
A cap matters on mobile: retrying a dead endpoint every minute forever is a battery drain, and a visible βretryβ control is a better experience than an invisible loop.
Validation & Monitoring Permalink to this section
Verify the shape of the delays, not just that reconnection happens.
// Log the ladder; it must grow and must not repeat the same value.
// attempt 1: 743 ms
// attempt 2: 1 214 ms
// attempt 3: 3 902 ms
// attempt 4: 6 118 ms
// attempt 5: 14 233 ms β growing, and never identical between runs
# Population test: stop the server with many clients connected, restart after 30 s,
# and watch the arrival rate of reconnects at the load balancer.
sudo systemctl stop sse-server; sleep 30; sudo systemctl start sse-server
# Fixed retry β one enormous spike.
# With jitter β a broad, low ramp over the interval.
# Reconnect arrival rate β the recovery curve should be a ramp, not a spike.
sum(rate(sse_connects_total[30s]))
# Peak-to-median ratio after an incident: closer to 1 is better.
max_over_time(sum(rate(sse_connects_total[30s]))[10m:])
/ avg_over_time(sum(rate(sse_connects_total[30s]))[10m:])
Client-side, report the attempt number that eventually succeeded. A distribution concentrated at attempt one means reconnects are healthy; a long tail means the endpoint is unreachable for a subset of users, which no backoff strategy can fix.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Can I change the browser's built-in retry behaviour?
Only its interval, by sending a retry: field. Growth and jitter are not part of the algorithm, so implementing them means taking over: close the connection in the error handler, wait your own computed delay, and construct a new EventSource. Sending a jittered retry: per client is the low-effort middle ground.
Which jitter strategy should I use?
Full jitter β a uniform pick between zero and the exponential cap β unless an immediate retry is wasteful for your handshake, in which case equal jitter keeps a floor. Both beat no jitter by a wide margin; the difference between them is small compared with the difference between having jitter and not.
Why reset the attempt counter on open rather than on the first event?
Because a quiet stream may not deliver an event for minutes, and treating that as failure would keep the ladder short and re-synchronise the population. open means the response headers validated, which is the real signal that the connection works.
Should the client ever stop retrying?
Yes. After a dozen failures the endpoint is not coming back within the user's attention span, and retrying every minute indefinitely costs battery and bandwidth. Stop, show an explicit retry control, and reconnect immediately when the page becomes visible again β that is when the user is actually there.