Reconnect and Backoff
Exponential backoff with jitter, why a server restart without jitter produces a synchronised stampede, and how to say "reconnecting" to a user without saying it a hundred times.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
The connection dropped — how soon should I try again, and what happens when every client asks that question at the same instant?
Someone's train went into a tunnel. They come out thirty seconds later and expect the page to be live again, without having thought about it and without having pressed anything.
On close, reconnect. Maybe wait a second first so we do not spin. Every client does the same thing and the connection comes back as soon as the network does.
The disconnect was not the network — the server restarted. Every connected client sees close within the same few milliseconds, waits the same one second, and arrives together. The service that has just started, with cold caches and empty connection pools, is hit by its entire client population at once (Retry Storms: The Load You Generated Yourself in Observability).
- The disconnect was not the network — the server restarted. Every connected client sees
closewithin the same few milliseconds, waits the same one second, and arrives together. The service that has just started, with cold caches and empty connection pools, is hit by its entire client population at once (Retry Storms: The Load You Generated Yourself in Observability). - It survives that and the second wave lands one second later, and the third one second after that, because a fixed delay preserves the synchronisation instead of dissolving it.
- The reason for the close was
401. The client retries forever against an endpoint that will never accept it, generating load and never recovering (Session Expiry and the Refresh Race). - The connection succeeds and drops again immediately. Because the attempt counter resets on
open, the client is now in a tight loop at the shortest delay, hammering the server at the worst possible moment. - The user sees a toast for every attempt. After ninety seconds in a tunnel there are twenty toasts, and a screen reader has announced all of them (Live Regions and Announcement).
What is actually happening
In the browser, not in the framework.
- Backoff exists to bound the load a failing dependency receives. Doubling the delay after each failure turns an unbounded retry rate into a logarithmic one, which is what lets a struggling service recover instead of being held down (Without Jitter, Every Client That Failed Together Retries Together in Backend Engineering).
- Jitter exists for a different reason: to break correlation. Backoff bounds the rate per client; jitter spreads clients that all failed at the same instant across time. Without it, a common failure creates a permanent convoy — every client retries in lockstep forever.
- The convoy forms because the failure event is shared. A server restart, a load balancer draining, a deploy — all of them disconnect thousands of clients within the same instant, so every client's clock starts together.
- Full jitter means sleeping a random duration between zero and the current backoff ceiling, rather than the ceiling itself. It preserves the growth of the ceiling and destroys the correlation entirely.
- A cap matters as much as the growth: an uncapped doubling reaches delays measured in hours, and a user who left the tab open overnight is looking at a page that gave up while they slept.
- Attempt counters must reset on *stability*, not on connect. A connection that opens and dies immediately is a failure, and treating it as a success is what turns backoff into a tight loop (Timeouts: The Latency Contract Nobody Writes Down in Observability).
What this makes the browser do
And which of it is avoidable.
- Each attempt is a full connection setup: DNS if the entry expired, TCP, TLS, then the handshake. On a bad network these are the expensive part, which is another reason not to attempt often (The TLS Handshake in Networking).
- Timers in a backgrounded tab are throttled heavily, so a schedule of "every few seconds" becomes "occasionally". Reconnection logic must be correct when its timer fires much later than requested (Long-Lived Clients and Version Skew).
- Every failed attempt that fires a state update re-renders whatever renders connection status. A status indicator that re-renders a large tree per attempt is a self-inflicted cost (What a Component Costs to Render).
- Avoidable work: retrying while the tab is hidden or the browser reports the device offline. Listening for
onlineandvisibilitychangeand attempting *then* is both cheaper and faster than a blind timer.
The stampede, drawn
The failure is not intuitive from one client's perspective, which is why it keeps being shipped. Look at one client and a fixed one-second retry is obviously reasonable. Look at the population and the shared failure event has made every client a copy of the same client, and the retry is a coordinated attack timed to the moment the service is least able to absorb it.
The timeline is schematic and in relative units. What matters is the shape: on the left, every arrival lands in the same column and keeps landing there. On the right, the same clients with full jitter spread across the window, and the service sees a load it can actually serve while it finishes starting (Saturation: The Reading Utilization Cannot Give You in Observability).
- Server down / restarting — Every connected client sees
closewithin the same instant. The correlation is created here, not by the retry policy. - Fixed delay: all four retry — Wave one. Rejected together, because the server is still starting.
- Fixed delay: all four retry again — Wave two, identical shape. A fixed delay preserves the convoy instead of dissolving it.
- Fixed delay: all four retry again — The server comes up into a wall. Every recovery attempt is also the next outage.
- Jittered: client A retries — Same ceiling, random point below it.
- Jittered: client C retries — Succeeds — the server had capacity because A and B did not arrive at this instant.
- Jittered: client D retries — Recovery is spread across the window instead of stacked in one column.
Both halves use exactly the same backoff ceiling. The only difference is whether each client picks a random point below it, and that difference is the difference between a recovery and a second outage.
A policy worth copying
There is not much code here, and every line of it corresponds to a failure mode above. The random draw is the stampede fix. The cap is the abandoned-tab fix. The stability window is the flapping fix. The close-code branch is the retry-forever-against-a-401 fix.
The two event listeners at the bottom matter more than they look. They convert a policy that is patient by design into one that feels instant when the user's circumstances change — coming out of a tunnel, or switching back to the tab — without weakening the policy at all.
const delay = Math.min(CEILING, BASE * 2 ** attempt)
const delay = Math.random() * Math.min(CEILING, BASE * 2 ** attempt)
The first bounds the rate of one client and leaves the population perfectly correlated, so ten thousand clients arrive as one enormous client at every doubling. The second bounds the rate identically and spreads arrivals uniformly under the ceiling, which is the property that lets a restarting service actually finish restarting.
1const BASE = 500 // shortest delay, in ms — a protocol knob, not a performance claim2const CEILING = 30_000 // longest delay; an abandoned tab must not wait an hour3let attempt = 04let timer: number | undefined5let openedAt = 06 7function scheduleReconnect() {8 if (timer !== undefined) return // one loop, never two9 const ceiling = Math.min(CEILING, BASE * 2 ** attempt)10 const delay = Math.random() * ceiling // FULL jitter: anywhere in [0, ceiling)11 attempt++12 timer = window.setTimeout(() => { timer = undefined; connect() }, delay)13}14 15function onOpen() {16 openedAt = Date.now()17 setStatus('live')18 // NOTE: attempt is deliberately NOT reset here.19}20 21function onClose(code: number) {22 setStatus('reconnecting')23 if (code === 1008 || code === 4401) { setStatus('signed-out'); return } // never retryable24 // Reset only if the connection was stably up. Otherwise a flapping socket25 // resets the counter on every cycle and backoff degenerates into a tight loop.26 if (Date.now() - openedAt > 10_000) attempt = 027 scheduleReconnect()28}29 30// Short-circuit the wait when the world changes underneath the timer.31window.addEventListener('online', () => { clearTimeout(timer); timer = undefined; connect() })32document.addEventListener('visibilitychange', () => {33 if (document.visibilityState === 'visible' && !isOpen()) { clearTimeout(timer); timer = undefined; connect() }34})The delays here are protocol parameters chosen by you, not measurements of anything — pick them from your own reconnect data. The load-bearing lines are the Math.random() multiplication, the stability check before resetting attempt, and the early return for close codes that will never succeed on retry.
Telling the user, without telling them a hundred times
A reconnecting UI has two jobs and they pull against each other: never let someone act on data that stopped updating, and never become the loudest thing on the screen. The resolution is to announce state transitions rather than attempts, and to describe the consequence rather than the mechanism — "showing data from a few minutes ago" is useful, "retry attempt 14" is not.
This is also where the accessibility failure is easiest to ship. A polite live region updated on every attempt is a screen reader reading a counter aloud for as long as the tunnel lasts, and an assertive one interrupts whatever the user was reading, every time (Live Regions and Announcement).
semantics A role="status" region (implicitly aria-live="polite", aria-atomic="true") holding a short text state, plus a real button for manual retry once automatic attempts are exhausted. Never a bare coloured dot, and never aria-live="assertive" for a state that can flap.
| Tab | Reaches the retry button in its natural document order once it exists; it is not a focus trap and does not jump the order. |
| Enter / Space | Triggers an immediate reconnect attempt and resets the attempt counter. |
| Escape | Dismisses a non-modal reconnecting notice if one is shown, without stopping reconnection itself. |
- — Never move focus when the connection state changes. The user may be mid-form, and a disconnect is not a reason to take their cursor (Focus Management).
- — The retry control appears in the reading order where the status is, so it is discoverable by the user who just heard the status.
- — If reconnection ultimately fails and the view must be replaced, move focus deliberately to the heading of the replacement rather than letting it fall to the document.
- — On transition to disconnected: one announcement naming the consequence — "Disconnected. Showing data from a few minutes ago."
- — On transition back to live: one announcement — "Reconnected. Data is current." Nothing in between.
- — Nothing per attempt. The attempt count is developer information and belongs in a log, not in a live region.
- — When automatic attempts stop: one announcement naming the manual control, so the user knows a recovery path exists.
usually broken by The pattern invites announcing every attempt — the status text is already in a live region, so updating it per attempt costs one line and turns a screen reader into a metronome for the duration of the outage. The second-most-common break is signalling connection state with colour alone, which conveys nothing to a screen-reader user and little to a colour-blind one.
How to build it
Most important first.
- Classify the close before scheduling anything. Authentication failures go to sign-in, policy failures stop permanently, and everything else is a network problem to be retried (Login Redirects and the Open-Redirect Trap).
- Exponential growth, a cap, and full jitter. The jitter is not a refinement — it is the part that prevents the stampede, and a backoff without it is a synchronised backoff.
- Reset the attempt counter only after the connection has been stably open for a while, not the moment
openfires. - Use platform signals to short-circuit the wait: on
onlineand on the tab becoming visible, attempt immediately regardless of where the timer was. Users forgive a slow reconnect far less when they can see the network bar is full. - Cap total attempts or elapsed time, then stop and offer a manual retry. Infinite background retry from a tab someone abandoned two days ago is load your service pays for and nobody benefits from.
- Re-authenticate and re-subscribe on every successful reconnect, then resynchronise before showing the UI as live. A connection that is open but out of date is worse than one that is visibly down (Resynchronisation After a Gap).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- Connection state is information, so it needs a text representation, not only a coloured dot. A polite status region carrying "Live", "Reconnecting", "Offline — showing data from a few minutes ago" is readable by everyone (Live Regions and Announcement).
- Announce transitions, not attempts. One announcement when the connection is lost and one when it returns; twenty announcements of "attempt 14" is why people switch screen readers off.
- Do not use an assertive live region for connection state. It interrupts whatever is being read, and a flapping connection then interrupts continuously — the announcement becomes an obstacle rather than information.
- If reconnection is exhausted and a manual retry is offered, that control must be a real focusable button in the reading order, and focus must not be stolen to it — the user may be in the middle of something (Focus Management).
- Respect reduced-motion preferences in any reconnecting indicator: a permanently spinning element is a distraction for some users and a vestibular problem for others (Contrast, Colour and Motion).
What can go wrong
- No jitter: the convoy. Every client retries in lockstep and the service never gets a quiet moment to finish starting.
- Reset-on-connect: a flapping connection produces a tight retry loop at minimum delay, precisely against a server that is already unhealthy.
- Retrying non-retryable closes: an expired session or a rejected origin retried forever, filling logs and never recovering (Retryability: Telling Clients What To Do Next in API Design).
- Multiple reconnect loops: two modules each own a socket, each with its own backoff, doubling the load and interleaving their state updates (Who Owns This State?).
- The mitigation failing: adding a heartbeat to detect dead connections, then setting its timeout tighter than a mobile network's normal pause, so healthy connections are torn down and the backoff loop runs on a working network.
- A UI that never says anything, so a user acts on numbers that stopped updating four minutes ago (Loading, Error, Empty — The States You Did Not Render).
- A scheduled reconnect racing a manual retry, or racing an
onlineevent, producing two attempts and briefly two live connections that each deliver every message (Ordering and Duplicate Delivery). - A successful reconnect racing the old socket's
closehandler: the handler fires after the new connection is open and schedules another reconnect against a healthy socket. - The ticket fetch racing the backoff timer — the credential expires while waiting for the delay to elapse, so the attempt fails on a token that was valid when it was requested.
- Resubscribe racing inbound events: the server begins sending before it has processed the subscription, so the first events after reconnect may not be the ones this client asked for.
- Reconnection is re-authentication. A design that reuses the original credential indefinitely means a revoked session survives as long as the client keeps reconnecting (Session Expiry and the Refresh Race).
- Close codes must be classified conservatively: retrying an authorization failure is not just useless, it is a client generating credential-rejection load that looks exactly like an attack to your own detection (Rate Limiting as a Security Control in Security).
- A reconnect stampede is a self-inflicted denial of service, and it is indistinguishable at the edge from an external one — which means your own clients can trigger the defences meant for attackers (Rate Limiting in Backend Engineering).
- A ticket or token fetched for a reconnect should be single-use and short-lived, so that a captured attempt cannot be replayed later (Replay Attacks in Security).
- "Exponential backoff is enough." Backoff bounds one client's rate. Jitter is what stops ten thousand clients from being one client with ten thousand times the impact (Without Jitter, Every Client That Failed Together Retries Together in Backend Engineering).
- "Jitter is a small optimisation." It is the entire mechanism by which a correlated failure decorrelates. Without it, a shared failure event keeps the convoy synchronised indefinitely.
- "Reconnected means recovered." It means the transport is back. What you missed while it was gone is a separate problem with a separate solution (Resynchronisation After a Gap).
- "Retry everything." A close for authentication or policy reasons will never succeed on retry, and retrying it wastes the user's battery and your rate limit (Retryability: Telling Clients What To Do Next in API Design).
- "The user does not need to know." A page that quietly stopped updating is the most dangerous state a real-time UI has, because it looks exactly like a page where nothing has happened.
Measuring it, and what changes in the field
- Reconnect count and total reconnect duration per session, from the field. Local development produces zero of both, so this is the metric that does not exist until you add it (Real User Monitoring).
- A histogram of close codes. It separates "networks are bad" from "we are closing connections during deploys" from "clients are being rejected", and those have completely different fixes (Frontend Error Tracking).
- Server-side connection rate during a deploy. A spike shaped like a comb is a missing jitter; a smooth ramp is a working one (Saturation: The Reading Utilization Cannot Give You in Observability).
- Time from
onlinefiring to the first message received — the number the user actually experiences coming out of the tunnel.
- On a mobile network, brief disconnects are constant and a slightly patient reconnect is invisible to the user; on a wired desktop, a disconnect usually means something real, so the same policy feels sluggish.
- In a backgrounded tab, timers are throttled and the reconnect schedule stretches. Reconnecting on visibility rather than on the timer is what makes returning to a tab feel instant (The Multi-Process Browser).
- On a large deployment, the difference between jitter and no jitter is the difference between a recovery and a second outage; at a hundred clients nobody would ever notice.
- On a slow device, the burst of replayed events after a reconnect can block the main thread at the exact moment the user is looking for confirmation that things are working again (Long Tasks).
- Backoff trades recovery latency for stability: a client that has been failing for a while waits longer than it strictly needs to when the service comes back. Reconnecting on
onlineand on visibility buys most of that back. - Jitter makes reconnection non-deterministic, which makes it harder to test and harder to reason about in a bug report. That is the cost of the property that makes it work.
- Giving up after a bounded number of attempts protects your service and produces a page that has to be manually revived — which is the right trade only if the manual control is obvious.
- Resetting the counter on stability rather than on connect means a genuinely brief blip is treated with more suspicion than it deserves.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALExponential growth, a ceiling and randomisation are transport-independent — the same policy applies to a WebSocket, a fetch retry and a background sync — and nothing about it depends on a particular browser or engine.
- NETWORK-SPECIFICThe right minimum delay depends on the network the client is actually on: a mobile radio pause is routinely long enough that an aggressive first retry fails before the connection could have succeeded, while on a wired connection the same delay merely feels slow.
- BROWSER-SPECIFICTimer throttling in hidden tabs is an implementation policy that differs by browser and by whether the device is on battery, so a reconnect schedule expressed purely in timers will fire on a schedule you did not choose; visibility and
onlineevents are the portable signals.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Distributed Systems — correlated failure, load shedding and why the recovery of a large client population is itself a capacity planning problem for the service they are recovering onto.