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.

Fixed retry turns an outage into a repeating pulse Timeline of a population reconnecting on a fixed interval: every client returns together, fails together, and returns again one interval later. Fixed retry turns an outage into a repeating pulse time β†’ Service drops all clients disconnect 0 s All return service still starting 3 s All return again same wave, same size 6 s Recovery stalls load never subsides 9 s
The recovering service meets the entire population at once, fails, and meets it again three seconds later β€” which can extend an outage indefinitely.

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

Attempts per client during a five-minute outage Bar chart of reconnect attempts made by one client over a five-minute outage under fixed, linear, exponential and capped-exponential backoff. Attempts per client during a five-minute outage one client, 300-second outage Fixed 1 s 300 Linear +1 s 24 Exponential Γ—2 8 Exponential, capped 30 s 12 reconnect attempts
Growth reduces per-client load; the cap keeps a returning service discoverable within seconds rather than minutes.

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.

Peak reconnects per second when the service returns Bar chart of peak reconnect rate against a recovering service under no jitter, equal jitter and full jitter. Peak reconnects per second when the service returns 50 000 clients disconnected simultaneously No jitter 50 000 Equal jitter 4 200 Full jitter 1 900 peak reconnects per second
Jitter matters more than growth: exponential backoff without randomisation still produces synchronised waves, just further apart.
// 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.