Retries, and the Duplicate Order
Retrying a non-idempotent mutation is a correctness bug wearing resilience as a costume. Retry only what is safe, with backoff, jitter and a cap.
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.
Which failed requests may I retry automatically, and what turns a retry from resilience into a second charge on someone's card?
Someone on a train presses Buy. The connection drops for two seconds. They want the purchase to go through — once — and they want to be told, honestly, if it did not.
Wrap the request in a loop and try it three times before giving up. Networks are flaky, retries are the standard remedy, and three attempts costs nothing when the first one usually works.
The request may have succeeded. A response lost on the way back is indistinguishable from a request lost on the way out, so a retry after a timeout can create a second order (Idempotency in API Design).
- The request may have succeeded. A response lost on the way back is indistinguishable from a request lost on the way out, so a retry after a timeout can create a second order (Idempotency in API Design).
- A 400 will fail identically every time. Retrying a validation error is pure waste, and it delays the error message the user needs by however long the backoff takes (Validation Errors: Feedback, Not Verdicts in API Design).
- Immediate retries arrive while the server is still struggling. Every client retrying at once turns a brief degradation into a sustained overload — the same event, amplified by its own clients (Retry Storms: The Load You Generated Yourself in Observability).
- Retries multiply. Three retries in the fetch wrapper, three in the data library and three in the service worker is twenty-seven requests for one user action, and nobody wrote that number down.
- The user has no idea. A silent retry loop looks identical to a slow request, so the person is waiting through three failures with no information and no way to stop (Loading, Error, Empty — The States You Did Not Render).
What is actually happening
In the browser, not in the framework.
- A retry is only safe when repeating the request cannot change the outcome. That property is idempotency, and it is a property of the endpoint, not of the client (Idempotency in API Design).
- By HTTP semantics
GET,HEAD,PUTandDELETEare defined as idempotent andPOSTis not — but the definitions describe intent, and aPUTimplemented as an append is not idempotent whatever the method says (HTTP Methods Are Promises in API Design). - The safe way to retry a non-idempotent operation is to make it idempotent: the client generates a key, sends it with every attempt, and the server deduplicates on it. Now a retry is a question — "did this one happen?" — rather than a second instruction (Idempotency Keys: The Mechanism in API Design).
- Status codes classify retryability. 408, 429 and most 5xx are worth another attempt; 4xx other than those will fail identically. A 429 usually carries a
Retry-Afterthat is more authoritative than any backoff formula you invented (The Rate-Limit Contract in API Design). - Backoff spreads attempts out so the server gets room to recover; jitter decorrelates clients so they do not all return at the same instant. Backoff without jitter reschedules the stampede rather than dispersing it (Without Jitter, Every Client That Failed Together Retries Together in Backend).
- Every retry policy needs a cap on attempts *and* an overall deadline. Without the deadline, exponential backoff eventually schedules an attempt further away than the user's patience (Deadlines vs Timeouts in Concurrency).
What this makes the browser do
And which of it is avoidable.
- Each attempt is a full request: connection (or reuse), headers, transfer, decompression, parse. A retry is not cheaper than the original.
- Backoff timers are ordinary tasks, and they are throttled in background tabs — a retry scheduled for a tab that gets backgrounded may fire much later than intended, against a page the user has forgotten (The Multi-Process Browser).
- A retry loop holds its closure alive for the whole schedule: the request, the payload, and everything captured with them (Memory Leaks).
- Avoidable: retrying a parse failure or a validation error, both of which do the full network round trip to produce the same answer.
The only question that matters first
Before any backoff formula, one question decides everything: if this request runs twice, what happens? Not "is it likely to run twice" — if it runs twice. For a GET /orders the answer is nothing. For a POST /orders with no idempotency key the answer is two orders, and no amount of careful scheduling makes that acceptable.
The decision below is deliberately not a table of status codes. Status codes tell you whether the *server* thinks another attempt could succeed; they say nothing about whether repeating the operation is safe, and that second question is the one that produces the duplicate charge.
A request failed. Do you send it again without asking the user?
when Network failure, timeout, 429, or 5xx on an operation with no side effects.
cost Latency. The user waits through the backoff, so the overall deadline must be shorter than their patience (Tail Latency: Why p50 Being Fine Does Not Help in Observability).
when The client generated a key, sends the same key on every attempt, and the API deduplicates on it.
cost Requires API support and server-side storage. Regenerating the key per attempt silently removes the entire protection (The Idempotency Key Flow in Backend).
when Any POST that creates, charges, sends or dispatches, on an API with no deduplication.
cost The user has to decide, and you must tell them honestly that the outcome is unknown — not that it failed (Idempotency in API Design).
when Validation errors, unauthorised, forbidden, not found. The request is wrong, not unlucky.
cost None. Retrying these only delays a message the user needs immediately (Validation Errors: Feedback, Not Verdicts in API Design).
when Status was 2xx and the body did not match the contract.
cost None. The server already succeeded; the same request will produce the same unparseable answer (The Life of a Fetch).
when Always. The request was deliberately stopped.
cost None, and retrying it is an outright bug (Cancelling a Request Nobody Is Waiting For).
when navigator.onLine is false, or every attempt fails instantly.
cost Needs a queue and a resume path, which is a considerably larger design than a retry loop (The Offline Mutation Queue).
Backoff, jitter, a cap and a deadline
All four exist for different reasons and removing any one of them reintroduces a distinct failure. Backoff gives the server room. Jitter stops every client that failed together from returning together. The attempt cap bounds the work. The overall deadline bounds the *wait*, which is the only one of the four the user can feel.
The implementation below is short because the difficulty is not in the arithmetic. It is in the classification above it and in the two lines that make it cancellable — a backoff timer that outlives its component is a request sent to a screen nobody is looking at.
1type Fail = { kind: 'network' } | { kind: 'http'; status: number; retryAfterMs?: number }2 | { kind: 'parse' } | { kind: 'aborted' }3 4const RETRYABLE = (f: Fail) =>5 f.kind === 'network' ||6 (f.kind === 'http' && (f.status === 408 || f.status === 429 || f.status >= 500))7// note what is absent: 'parse' and 'aborted' are never retried,8// and no 4xx other than the two the server uses to say "try later".9 10async function withRetry<T>(11 attempt: (signal: AbortSignal) => Promise<Result<T>>,12 opts: { maxAttempts: number; baseDelay: number; deadline: number; signal: AbortSignal },13): Promise<Result<T>> {14 const until = Date.now() + opts.deadline // bounds the WAIT, not the attempts15 let last: Result<T>16 17 for (let n = 0; n < opts.maxAttempts; n++) { // bounds the WORK18 last = await attempt(opts.signal)19 if (last.kind === 'ok' || last.kind === 'aborted') return last20 if (!RETRYABLE(last)) return last // classify before scheduling anything21 22 // The server's own guidance outranks our formula.23 const backoff = last.retryAfterMs ?? opts.baseDelay * 2 ** n24 const jittered = Math.random() * backoff // full jitter: decorrelates clients25 if (Date.now() + jittered > until) return last // no attempt nobody will wait for26 27 await sleep(jittered, opts.signal) // cancellable, or the timer outlives the page28 if (opts.signal.aborted) return { kind: 'aborted' }29 }30 return last!31}Full jitter — a uniform draw from zero to the backoff, rather than the backoff plus a wobble — is what actually spreads a synchronised fleet of clients. And sleep takes the signal: without it, cancellation stops the request but not the schedule, and the retry fires into a component that has been gone for seconds.
How a retry becomes an incident
Every row here starts with a reasonable intention. That is the point: none of these are careless, and each of them shipped because it made something more reliable in the case the author was thinking about.
The first row is the one to remember. It is not a performance problem or a UX wrinkle — it is money moved twice, and the frontend caused it by retrying an operation the API never promised was repeatable (Idempotency in API Design).
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
POST /orders times out; the wrapper retries | Two orders, two charges, one very unhappy customer | The response was lost, not the request. A timeout is an unknown outcome, not a failure | Send a client-generated idempotency key with every attempt, or do not retry the write at all (Idempotency Keys: The Mechanism in API Design). |
| A backend degrades; every client retries three times | Load quadruples exactly when the server is weakest; the outage lengthens | Synchronised retries with no jitter and no client-side circuit breaking | Full jitter, a global attempt budget, and back off harder on 429 and 503 (Retry Storms: The Load You Generated Yourself in Observability). |
| Retries in the fetch wrapper, the data library and the service worker | Twenty-seven requests from one click | Three independently reasonable policies compose multiplicatively | Own retries in exactly one layer and make the others pass failures through unchanged. |
| A 422 validation error is retried | The user waits through the whole backoff for a message that was ready immediately | No classification: the policy retried on "not 2xx" rather than on "could succeed later" | Retry only on 408, 429 and 5xx, plus network failures (Validation Errors: Feedback, Not Verdicts in API Design). |
| The idempotency key is regenerated on each attempt | Duplicates, despite an idempotency mechanism being present | The key identifies the *operation*, not the attempt | Generate the key once, at the moment of user intent, and carry it through every retry (The Idempotency Key Flow in Backend). |
| The device goes offline mid-schedule | All attempts exhausted within seconds; nothing left when the signal returns | Instant failures consume the attempt budget at full speed | Detect offline, stop the schedule, and resume on the online event (Offline UX). |
How to build it
Most important first.
- Classify before retrying. The decision is a function of the failure — network, timeout, 429, 5xx, 4xx, parse, abort — and each class gets an explicit answer. A retry policy with no classification is a loop.
- Retry reads freely; retry writes only with an idempotency key that the server honours. If the API does not support one, do not retry the write — surface it and let the person decide (Idempotency Keys: The Mechanism in API Design).
- Use exponential backoff with jitter, an attempt cap, and an overall deadline. All four, not the first one.
- Respect
Retry-Afterwhen the server sends it. It is the server telling you when it will be ready; guessing over the top of it is how a rate limit becomes an outage (The Rate-Limit Contract in API Design). - Retry in exactly one layer. Pick it — usually the data layer — and make every other layer pass failures through, or the effective attempt count is a product rather than a sum.
- Never retry an
AbortError. It was not a failure, and retrying it re-sends a request the application deliberately stopped (Cancelling a Request Nobody Is Waiting For). - Make retries visible for anything the user initiated. "Retrying (2 of 3)" with a Cancel is honest; a silent loop is not (Loading, Error, Empty — The States You Did Not Render).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- A silent retry loop extends the pending state without telling anyone. For a screen-reader user the region simply stays busy, with no indication that anything is being attempted (Live Regions and Announcement).
- Announce retry attempts politely and sparingly — the attempt count on a user-initiated action, not every attempt of a background refresh.
role="status", neverrole="alert", for something still in progress. - Provide a keyboard-reachable Cancel while retrying, and make sure it is not the element being disabled and re-enabled as attempts cycle — focus must not be destroyed by a state change the user did not cause (Focus Management).
- When the retries are exhausted, the final failure must be announced assertively and the retry control must be focusable. A loop that gives up silently is the worst of both designs (Errors People Can Actually Perceive).
What can go wrong
- The duplicate mutation: a
POST /ordersretried after a timeout, with no idempotency key, producing two orders. The user sees one error and two charges. - Retry amplification across layers, where three independently reasonable policies compose into a request storm from a single click (Retry Storms: The Load You Generated Yourself in Observability).
- Retrying 4xx: a request with a malformed body retried three times, delaying the validation message by the whole backoff schedule.
- Backoff without jitter: every client that failed together returns together, and the recovering server is knocked over by the recovery (Without Jitter, Every Client That Failed Together Retries Together in Backend).
- A retry scheduled after the component unmounted, sending a request for a screen that no longer exists and writing into a store nobody reads.
- The mitigation failing: an idempotency key regenerated on each attempt, which makes every retry a brand-new operation and defeats the entire mechanism.
- Retrying while offline, burning the attempt budget in the first few seconds so that nothing is left when connectivity returns. Wait for the
onlineevent instead (Offline UX).
- A retry and the original can both be in flight if the timeout fired without aborting. Two attempts, two responses, arriving in either order, both writing to the same state (Out-of-Order Responses).
- A retry scheduled by a timer races the component teardown: the abort must also cancel the pending timer, or the request goes out after cancellation (Cancelling a Request Nobody Is Waiting For).
- A retry can race a token refresh. The attempt built before the refresh carries the old credential and fails with 401, which an unclassified policy will then retry again with the same stale token (Auth Across Tabs).
- Retries multiply load, and an attacker can use that. A client that retries aggressively on 5xx is an amplifier that turns a small server problem into a large one (Rate Limiting in Backend).
- Every attempt re-sends credentials. If a request is being retried against an endpoint that has started returning 401, the loop is repeatedly presenting a token that is no longer valid, which looks exactly like credential stuffing from the other side (Session Expiry and the Refresh Race).
- An idempotency key must be unguessable and scoped to the session. A key an attacker can predict lets them collide with someone else's operation (Idempotency Keys: The Mechanism in API Design).
- Do not retry through a redirect to a different origin. Re-sending a body and credentials to a host you did not originally address is a disclosure path.
- "Retries make the app more reliable." Retries make *reads* more reliable. On writes without an idempotency key they trade an error for a duplicate, which is usually the worse outcome.
- "A timeout means the request did not happen." A timeout means you stopped waiting. The server may have completed the work and the response may have been lost on the way back.
- "
POSTis fine to retry if it is fast." Speed has nothing to do with it. Repeatability does. - "Exponential backoff is enough." Without jitter, backoff synchronises clients instead of dispersing them; without a cap it eventually schedules attempts nobody is waiting for (Without Jitter, Every Client That Failed Together Retries Together in Backend).
- "The library retries for me, so I do not have to think about it." Then the library is retrying your mutations too, and you should find out what its default policy is before production does (Retryability: Telling Clients What To Do Next in API Design).
Measuring it, and what changes in the field
- Count attempts separately from operations in the field. "Requests per user action" is the number that reveals amplification, and it is not on any default dashboard (Real User Monitoring).
- The Network panel shows the schedule directly: repeated identical requests with growing gaps is a working backoff, and repeated identical requests with no gaps is a bug (Debugging the Network).
- Server-side, watch for duplicate mutations arriving with the same payload and different keys — that is the signature of a client retrying without an idempotency key (Duplicate Detection in Backend).
- Track exhausted-retry rate as a separate metric from failure rate. The gap between them is how much your retries are actually buying (Release Health).
- On a flaky mobile connection, retries earn their keep: transient failures are genuinely common and genuinely transient.
- On a saturated network, retries make it worse. Every attempt competes with the attempt that might have succeeded (Latency Budgets: Spending 200 Milliseconds on Purpose in Observability).
- In a background tab, timers are throttled, so a backoff schedule stretches unpredictably and attempts can land minutes late (Long-Lived Clients and Version Skew).
- Offline, every attempt fails instantly, so an unguarded exponential schedule burns its whole budget before the user has walked out of the lift (The Offline Mutation Queue).
- Retries trade latency for success rate. A request that succeeds on the third attempt took three backoff intervals to do it, and the user waited through all of them — which is why the overall deadline matters more than the attempt cap.
- Idempotency keys require server support and server storage, and they push work onto an API team that may not have planned for it (Idempotency Storage in Backend).
- Retrying in one layer only means the layer that owns it must see every failure, which couples your fetch wrapper to your data layer more tightly than either would like.
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.
- GENERALThe rule that only repeatable operations may be retried is not a browser property at all — it holds for any client of any remote system. What the browser adds is timer throttling in background tabs and an
onlineevent, both of which change *when* attempts actually fire. - SPEC-EVOLVINGWhich status codes carry retry guidance, and how
Retry-Afterinteracts with rate-limit headers, is an area where the standards and common practice have both moved. Read the response the server actually sends rather than encoding a table of codes that was accurate when the wrapper was written (The Rate-Limit Contract in API Design). - FRAMEWORK-SPECIFICData libraries ship retry defaults, and they differ: some retry queries several times and mutations never, others retry both, and the classification of what counts as retryable varies. A policy you did not choose is still a policy you own.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Distributed Systems — at-least-once delivery is the only thing a retrying client can offer, so exactly-once is always a property of the receiver. The idempotency key is where a browser participates in that agreement.