cf.completefrontend
My progress
FUNDAMENTALS · MODULE 4

Retries, timeouts, and backoff

How a client survives a flaky network and a struggling server: give every request a deadline, retry only the failures that can heal, space the retries out with exponential backoff and jitter, and stop retrying when the server or the network is clearly down.

The problem: failures that heal and failures that spread

Picture a food delivery app. A user on a train taps Track my order. The phone is between two cell towers, the request leaves the device, and the answer never comes back.

The spinner turns for 40 seconds, then the screen shows "Something went wrong". Ten seconds later the train reaches a station and the network is fine again.

Nothing was really broken. The failure was transient: a short problem that heals by itself in a few seconds. One quiet retry after a second or two would have shown the order status, and the user would never have known. Instead the app waited far too long, then gave up at the first try.

Now picture the opposite day. The order service has a bad deploy and slows down. Every one of the 200,000 phones that have the app open polls it, and each of them is coded to "retry right away until it works".

The service was handling 3,000 requests per second. Within a minute it sees 30,000, because every failed request comes back several times. The extra load keeps it down long after the bad deploy is rolled back.

Both stories are about the same few lines of client code. Retry too little and users see needless errors. Retry too much, too fast, or all at once, and clients turn a small outage into a long one.

Two bar charts side by side, requests per second reaching an order service over one minute, one bar per 10 seconds, with the server's limit of 5,000 per second drawn as a line. Left, clients that retry at once with no limit: 3,000, then 12,000, 24,000, and 30,000 per second for the rest of the minute, far above the limit, so the service stays down. Right, clients with exponential backoff, jitter, and a cap of 4 attempts: 3,000, then 4,500, 3,900, 3,400, 3,100, and back to 3,000, under the limit.retry at once, no limitbackoff, jitter, 4 tries30k/sserver limit 5k/sone bar per 10 sone bar per 10 s
The same outage, two retry policies. Instant, unlimited retries multiply the load until the server cannot recover; backoff with jitter and a cap keeps the extra load small. The numbers are illustrative.

Four questions every request has to answer

A client that handles failure well answers four questions for every request it sends. The rest of the lesson takes them one at a time.

  1. How long do I wait? Every request needs a deadline, a timeout. Without one, a request that hangs keeps the spinner up forever, because fetch() has no timeout of its own.
  2. Should I try again? Some failures heal (a dropped connection, a 503 Service Unavailable). Others never will (a 404 Not Found, a 400 Bad Request). Retrying those only wastes time.
  3. Is it safe to try again? Reading data twice is harmless. Charging a card twice is not. A retry of a write needs an idempotency key so the server can spot the repeat.
  4. When, and when do I stop? Retries are spaced out with exponential backoff (each wait longer than the last) and jitter (a random part, so clients do not retry in step). A cap on attempts, a retry budget, and a circuit breaker decide when to stop.

Where this logic lives

In most apps you write the retry loop once: in a small fetchWithRetry helper, in your API client, or in the settings of a server-state library such as TanStack Query. The point of this lesson is to know what those defaults do and when to change them. Request races covers cancelling unwanted requests, and REST API design covers idempotency keys and Retry-After from the server's side.

The mental model: a loop with a deadline and a brake

Think of phoning a busy restaurant to book a table. If the line is busy, you call back, but not every second: first after a minute, then after five. If a recording says "call back after 6 pm", you wait until 6.

If they tell you they are closed on Mondays, calling again will not help. And if the call drops halfway through the booking, you open the next call with "I'm the person who just booked for 8 pm", so they do not book two tables.

Every part of that story maps to a piece of retry logic. Waiting longer each time is backoff. "Call back after 6" is the server's Retry-After header. "Closed on Mondays" is a permanent error, such as a 404.

"I'm the person who just booked" is an idempotency key. And if every call for an hour fails, you stop calling and try a different restaurant: that is a circuit breaker.

One request as a small state machine

In code, a request with retries is a loop. It is easiest to understand as a handful of states, with the request moving between them. Each arrow in the next figure is a decision your code makes.

A state machine for one request with retries. Sending, with a timer running, leads to Classify when a result, an error, or a timeout comes back. Classify leads to Done on success, to Failed on a permanent error or when the attempts run out, and to Waiting on a retryable failure. Waiting sleeps for a backoff with jitter and then goes back to Sending. A note says that a cancel stops the loop at once, and going offline pauses it without using up attempts.cancel: stop at onceoffline: pause, keep the triesSendingtimer runningClassifywhat came back?Donereturn the dataFailederror + RetryWaitingbackoff + jitterresultsuccessgive upretryablesleep ends
Every arrow is a decision in your code. The loop only goes round through Waiting, and only a bounded number of times.
  1. Sending. The attempt is on the wire with a timer running. It ends in one of three ways: a success, an error response, or the timer firing.
  2. Classify. The code looks at what came back and decides: success, a failure that might heal (retryable), or one that will not (permanent).
  3. Waiting. For a retryable failure with attempts left, the code sleeps for a backoff delay, then goes back to Sending. A Retry-After from the server sets the minimum wait.
  4. Done or Failed. A success returns data. A permanent error, a used-up attempt count, or a total deadline ends in Failed, and the UI shows an error with a Retry button.

Three clocks, not one

Beginners often think of "the timeout" as one number. In practice three different clocks run, and mixing them up causes most of the bugs in this lesson.

ClockWhat it limitsTypical value for a user-facing read
Attempt timeoutHow long one try may take before we give up on it5 to 10 s, based on the slowest normal responses
Backoff delayHow long we sleep between two triesGrows: up to 0.5 s, then 1 s, then 2 s, with randomness
Total deadlineHow long the user waits overall, across every try and every sleep10 to 20 s, then show the error

The total deadline is the one users feel. Four attempts with an 8-second timeout and waits of up to 0.5, 1, and 2 seconds can add up to more than 35 seconds. That is too long for a spinner. Either cap the total, or lower the per-attempt timeout, so the sum fits what a person will sit through.

Think first

A helper makes up to 3 attempts, each with a 10-second timeout, and waits up to 1 s and then up to 2 s between them. What is the longest a user can stare at the spinner, and what would you change?

Which failures to retry

The first rule of retrying: only retry when a second try has a real chance of a different result. A failure caused by the request itself (a missing field, a wrong ID, no permission) fails the same way every time. Retrying it only delays the error message and adds load.

The second rule: only retry automatically when repeating the request is safe. Reading the same data twice is harmless. Creating an order twice is a real bug that costs real money.

How fetch reports a failure

fetch() has two different ways to fail, and your code has to handle both. This surprises people who are used to libraries like Axios, which throw on any status outside 200 to 299.

  • The promise rejects. No HTTP response arrived. The error is a TypeError for network failures (connection dropped, DNS lookup failed, the server refused the connection) and also for CORS failures, which the browser deliberately makes look the same. If you passed a signal and it fired, the error is a DOMException named AbortError, or TimeoutError when the signal came from AbortSignal.timeout().
  • The promise resolves with an error status. A response arrived, but response.ok is false because the status is outside 200 to 299. fetch treats this as a success at the network level. You must check response.ok or response.status yourself.

The decision for each kind of failure

Here is the policy this course uses everywhere, and the one the REST API design lesson uses for its error states. The middle column is for a request that is safe to repeat: a read, or a write that carries an idempotency key.

What happenedRetry automatically?Why
Rejected with a TypeError (network error)Yes, with backoffThe connection broke; the next try often works. A CORS mistake also lands here, so the attempt cap matters
TimeoutError from your own timeoutYes, with backoffThe server or network was slow; a later try may be fast
AbortError (user or app cancelled)NeverSomeone decided they no longer want the answer
408 Request TimeoutYes, with backoffThe server gave up waiting for the request; a fresh try usually works
429 Too Many RequestsYes, after Retry-AfterThe server says when to come back; never sooner
502, 503, 504Yes, with backoffA gateway or upstream was down, overloaded, or too slow; 503 may carry Retry-After
500 Internal Server ErrorNo: show a Retry buttonA crash usually repeats; let the person decide, and show a request ID for support
401 UnauthorizedOnce, after refreshing the tokenA fresh token fixes an expired session; a second 401 will not heal
400, 403, 404, 409, 410, 422NoThe request is wrong, forbidden, gone, or in conflict; the same request gets the same answer
A flowchart. First: was the request cancelled? If yes, stop quietly. If no: is it safe to repeat? If no, never retry automatically and show the error. If yes: did a response come back? If no, it was a network error or a timeout, so retry with backoff. If yes, look at the status: 408, 429, 502, 503, and 504 mean retry with backoff, after Retry-After for 429; 401 means refresh the token and retry once; 500 and other 4xx mean stop and show the error with a Retry button.Cancelled?Stop quietlyyesnoSafe to repeat?No auto-retryshow the errornoyesGot a response?Retrynetwork, timeoutnoyesStatus?Retry408 429 502-504Refresh, retry401, onceShow error500, other 4xx
Four questions, asked in order. Most requests leave at the first or second question; only failures that can heal and are safe to repeat reach a retry. A 500 stops here, with a Retry button for the user.

Is it safe to repeat?

A request is idempotent when sending it twice has the same effect on the server as sending it once. HTTP defines GET, HEAD, OPTIONS, PUT, and DELETE as idempotent. PUT /cart/items/42 with a quantity of 2 sets the quantity to 2 however many times it arrives. POST /orders creates a new order each time, and PATCH with "add one" adds one each time.

The hard case is a write that failed with a network error or a timeout. The browser cannot know whether the request reached the server. Maybe it was lost on the way there, or maybe the server finished the work and only the answer was lost.

So the default for a non-idempotent write is: do not retry automatically. Show the error and a Retry button, keep the user's input, and let a person decide. The idempotency key chapter shows how to make such a write safe to retry.

ts
const RETRY_STATUS = new Set([408, 429, 502, 503, 504]);

function isRetryable(error: unknown, safeToRepeat: boolean): boolean {
  if (!safeToRepeat) return false;
  if (error instanceof DOMException) {
    return error.name === "TimeoutError"; // AbortError: someone cancelled
  }
  if (error instanceof TypeError) return true; // network failure
  if (error instanceof HttpError) return RETRY_STATUS.has(error.status);
  return false; // a bug in our own code: retrying will not fix it
}
HttpError is your own class, thrown when response.ok is false, carrying the status and headers. Anything unknown is not retried, so a typo in your parsing code does not trigger four slow attempts. A 401 is handled before this check, by refreshing the token once.

Check your understanding · Exercise 1

A product page loads reviews with GET /products/7/reviews. Which of these failures should the client retry on its own, with backoff? Select all that apply.

Timeouts: decide how long to wait

fetch() has no timeout option. If a server accepts the connection and then never answers, or a proxy holds the request open, the promise can stay pending for minutes. Your spinner turns the whole time, and your retry logic never runs, because nothing has failed yet. A missing timeout is the most common reason "retries don't work".

So every request a person waits on gets a deadline. Modern browsers make that one line, with AbortSignal, the same object you already use to cancel requests in the request races lesson.

AbortSignal.timeout(ms)

AbortSignal.timeout(8000) returns a signal that aborts itself after 8 seconds. Pass it to fetch and the request is cancelled when time runs out. The promise rejects with a DOMException whose name is TimeoutError. A user cancelling gives AbortError instead, so your code can tell "too slow, maybe retry" apart from "not wanted, stop".

The deadline covers the whole response, not just the headers. If the headers arrive at 2 seconds but the body is still streaming at 8, the abort also makes response.json() reject. That is usually what you want: the user is waiting for the data, not the headers.

It is supported in every current browser (Chrome and Edge 103, Firefox 100, Safari 16) and in Node.js. One catch: Chrome and Edge before version 124 reported the timeout as an AbortError, not a TimeoutError, so on those old versions a timed-out request looks like a cancel.

AbortSignal.any: a timeout and a cancel button together

A real request usually needs two reasons to stop: its deadline, and the component unmounting or the user typing a new query. fetch takes only one signal. AbortSignal.any([a, b]) builds a signal that aborts as soon as any of its inputs does, with that input's reason.

It has been in all major browsers since 2024 (Chrome 116, Firefox 124, Safari 17.4). For older browsers, a small fallback with one AbortController and listeners on both signals does the same job.

ts
async function getJson(url: string, cancel: AbortSignal, timeoutMs = 8_000) {
  // A new timeout for this attempt, merged with the caller's cancel signal
  const signal = AbortSignal.any([cancel, AbortSignal.timeout(timeoutMs)]);
  const response = await fetch(url, { signal });
  if (!response.ok) throw new HttpError(response.status, response.headers);
  return response.json(); // the same signal also limits reading the body
}

// Later, in the retry loop:
// TimeoutError  -> retryable (if safe to repeat)
// AbortError    -> stop at once, do not retry, do not show an error
One signal carries both reasons to stop. Because the error name differs, the retry loop knows which one fired.

Per attempt or in total?

Where you create the timeout decides what it limits. Create AbortSignal.timeout() inside the loop and each attempt gets a fresh 8 seconds. Create it once, before the loop, and it becomes a total deadline: the second attempt starts with whatever time is left, and the sleep between tries counts too.

Good clients use both: a short timeout per attempt, so one stuck connection does not eat the whole budget, and a total deadline, merged in with AbortSignal.any, that caps what the user waits. The sleep between attempts listens to the same signal.

A timeline from 0 to 16 seconds. Try 1 runs from 0 to 5 seconds and hits its 5-second attempt timeout. A jittered wait of 0.4 seconds follows. Try 2 runs from 5.4 to 10.4 seconds and times out too. A wait of 0.8 seconds follows. Try 3 starts at 11.2 seconds and is cut off at 15 seconds by the total deadline, after which the user sees the error and a Retry button.total deadline 15 stry 1timeouttry 2timeouttry 3cut offwaits 0.4 s, 0.8 s0 s5 s10 s15 s
Two clocks at work: a fresh AbortSignal.timeout(5000) for each try, and one total deadline made before the loop and merged in with AbortSignal.any. The total is what the user feels.

Picking the numbers

Pick timeouts from real data, not from a round number that feels safe. Look at the latency percentiles for the endpoint: p50 is the time half of the requests beat, p99 is the time 99 in 100 beat. If p99 is 1.2 seconds, an attempt that has taken 6 seconds is almost certainly stuck, and a fresh attempt is likely to be faster than waiting.

  • Small reads (a profile, a list page): a timeout of about 3 to 5 times p99, often 5 to 10 seconds.
  • Search as you type: short, about 3 to 5 seconds, because a newer keystroke will replace the request soon anyway.
  • Uploads and exports: no fixed short timeout. Use progress, or a timeout on "no progress for 30 seconds", and let the user cancel.
  • Writes that charge or create: a generous timeout. Timing out a write does not undo it on the server; it only makes the outcome unknown.

Check your understanding · Exercise 2

A search box sends fetch(url, { signal }) where signal = AbortSignal.any([controller.signal, AbortSignal.timeout(4000)]). Which statements are true? Select all that apply.

Backoff and jitter: how long to wait between tries

Once you know a failure is worth retrying, the next question is when. Retrying at once rarely helps: whatever broke 50 ms ago is very likely still broken. And if the server is failing because it is overloaded, an instant retry adds load at the worst moment.

Exponential backoff

Exponential backoff doubles the wait after each failure in a row. With a base of 500 ms, the waits are 0.5 s, 1 s, 2 s, 4 s, 8 s. A cap stops the growth, say at 8 s or 30 s, so a long outage does not lead to hour-long waits. The formula is one line: min(cap, base * 2 ** attempt), where attempt counts from 0.

A bar chart of six waits with a base of 0.5 seconds and a cap of 8 seconds. The ceiling doubles after each failure: 0.5, 1, 2, 4, 8, and 8 seconds, where the cap stops the growth. Inside each ceiling, one full-jitter pick is filled in: 0.31, 0.72, 1.4, 1.1, 6.2, and 3.5 seconds.dashed: the ceiling; filled: one full-jitter pickafter fail 10.5 safter fail 21 safter fail 32 safter fail 44 safter fail 58 safter fail 68 s, cap
The dashed bars are min(cap, base * 2 ** attempt); the filled part is one client's random pick inside it. A fourth wait can be shorter than the third: that is what spreads clients apart.

Why backoff alone is not enough: the thundering herd

Backoff spaces out the retries of one client. It does nothing about many clients that failed at the same moment. When a server goes down, every open tab sees its request fail within the same second.

With plain backoff they all wait exactly 0.5 s, all retry together, all fail together, all wait exactly 1 s, and so on. The load does not spread out: it arrives in sharp waves. This is the thundering herd.

The waves hurt most when the server comes back: a wave of 5,000 requests in the same 100 ms knocks it over again, and the clients back off in step once more. The outage outlasts the bug that caused it.

Jitter: let each client pick its own moment

Jitter adds randomness to each wait, so clients that failed together drift apart. The most common form, called full jitter, waits a random time between zero and the backoff value: random() * min(cap, base * 2 ** attempt). On the third failure the cap is 2 s, so one client waits 0.3 s, another 1.1 s, another 1.9 s. The waves flatten into a steady trickle.

Marc Brooker's 2015 post on the AWS Architecture Blog, "Exponential Backoff And Jitter", compared these strategies in a simulation of many competing clients. Backoff without jitter was clearly the worst; full jitter did the least total work, and decorrelated jitter finished slightly sooner. Full jitter is the standard answer in interviews.

Wait between tries:
Clients:
Server down for:
A bar chart of requests reaching the server every half second for the first 40 seconds, drawn against a dashed line at the server's limit of 500 per half second. No jitter, 1,000 clients, server down for 3 s: the busiest 100 ms after the first failure gets 1,000 requests (the server takes 100), 8,500 requests in total, and the last client is served at 71.5 s.requests per 0.5 s, no jitterdashed: server limit0 s10 s20 s30 s40 sdown
Each bar is half a second of traffic; the dashed line is what the server can take. Without jitter the retries arrive as tall spikes at the same moments on every client; with jitter they spread into a low, steady flow the server can drain.

No jitter, 1,000 clients, server down for 3 s: the busiest 100 ms after the first failure gets 1,000 requests (the server takes 100), 8,500 requests in total, and the last client is served at 71.5 s.

Try the lab with 1,000 clients and the server down for 3 seconds, after which it can serve 1,000 requests per second. Without jitter, every retry wave lands in a single 100 ms slot, the server can take only a tenth of it, and the rest back off together again: the last client gets through after more than a minute. With full jitter, the same clients spread out, the busiest 100 ms gets about 270 requests instead of 1,000, and everyone is through in about 11 seconds.

Equal jitter finishes sooner in this lab, in about 7 seconds, but its busiest 100 ms gets about 420 requests, half again as many as full jitter. The AWS simulation found the opposite order, so the ranking between jitter styles changes with the workload. The gap between no jitter and any jitter does not.

Decision Which wait formula should the retry loop use?

Backoff, no jitter

Wait min(cap, base * 2 ** attempt): 0.5 s, 1 s, 2 s, 4 s, exactly.

Pros

  • Predictable and easy to test
  • Fine for a single client talking to its own device or a local service

Cons

  • Clients that failed together stay in step and retry in waves
  • Waves can keep a recovering server down

Full jitter

Wait random() * min(cap, base * 2 ** attempt), anywhere from 0 up to the backoff value.

Pros

  • Spreads clients across the whole window, so the smoothest load
  • One line of code; the usual default

Cons

  • Some waits are very short, so one client may retry almost at once
  • Harder to assert exact timings in tests (inject the random function)

Equal jitter

Wait half the backoff, plus a random part up to the other half.

Pros

  • Never retries sooner than half the backoff
  • Still breaks up the waves

Cons

  • Spreads clients over only half the window, so higher peaks than full jitter
  • Slightly more work and a much later finish in the AWS simulation

Decorrelated jitter

Each wait is random between base and 3 times the previous wait, capped: min(cap, random(base, prev * 3)).

Pros

  • Good spread and slightly faster completion in the AWS simulation
  • Waits drift instead of following a fixed ladder

Cons

  • Needs the previous wait as state
  • Less familiar to readers of your code

What we pick: Use full jitter with a cap for anything many clients run at once, which is every web app. Equal jitter is a reasonable choice when a near-zero wait would look odd in the UI. Drop jitter only for a lone client, such as a desktop app talking to a local process.

When the server tells you how long: Retry-After

A server that sends 429 Too Many Requests or 503 Service Unavailable can add a Retry-After header. It holds either a number of seconds (Retry-After: 30) or an HTTP date. The short polling lesson shows how to parse both.

The rule for the client is simple: never retry sooner than Retry-After says. Use it as the minimum wait, and still add your own jitter on top if many clients get the same value. If Retry-After is longer than a person will wait, say 120 seconds, do not sleep behind a spinner. End the loop, say when to try again, and disable the button until then.

ts
const BASE_MS = 500;
const CAP_MS = 8_000;

const backoffDelay = (attempt: number) =>
  Math.random() * Math.min(CAP_MS, BASE_MS * 2 ** attempt); // full jitter

function sleep(ms: number, signal: AbortSignal): Promise<void> {
  return new Promise((resolve, reject) => {
    signal.throwIfAborted();
    const id = setTimeout(resolve, ms);
    signal.addEventListener("abort", () => { clearTimeout(id); reject(signal.reason); }, { once: true });
  });
}

async function withRetry<T>(
  run: (signal: AbortSignal) => Promise<T>,
  { signal, safeToRepeat, attempts = 4 }: { signal: AbortSignal; safeToRepeat: boolean; attempts?: number },
): Promise<T> {
  for (let attempt = 0; ; attempt++) {
    try {
      return await run(signal);
    } catch (error) {
      if (attempt + 1 >= attempts || !isRetryable(error, safeToRepeat)) throw error;
      // retryAfterMs reads Retry-After from a 429 or 503 (seconds or a date), else 0.
      // It is the minimum wait; the jittered backoff on top keeps clients apart.
      await sleep(retryAfterMs(error) + backoffDelay(attempt), signal);
    }
  }
}
The whole loop. The sleep listens to the same signal as the requests, so cancelling, or a total deadline built with AbortSignal.any, ends the wait at once. This code, with isRetryable from earlier, was tested in Node.js: 404 is tried once, 503 four times, Retry-After: 1 waits at least a second, and an abort during the sleep stops it immediately.

Check your understanding · Exercise 3

A chat app's 50,000 open tabs all lose their connection when a server restarts. Each tab retries with backoff of 1 s, 2 s, 4 s, 8 s and no jitter. What happens, and what helps? Select all that apply.

Making writes safe to retry: idempotency keys

The scariest failure in this lesson is a timeout on a write. The user taps Pay 42 euros. The request reaches the server, the card is charged, and then the response is lost: the phone switched from Wi-Fi to 4G at that exact moment. The browser only knows that it did not hear back.

Retry, and you might charge the card twice. Do not retry, and the user sees an error for a payment that worked, taps Pay again, and is charged twice anyway. Neither choice is safe until the server can recognize a repeat.

How a key makes a repeat harmless

An idempotency key gives one user action a name. The client makes a random ID, such as crypto.randomUUID(), when the user starts the action. It sends that ID in an Idempotency-Key header with the first attempt and with every retry.

The server stores the result of the first request under the key. When the same key arrives again, it does not charge again: it returns the stored result.

A sequence diagram between the checkout page, the payments API, and its key store. The page sends POST /payments with Idempotency-Key 7c9e. The API charges the card and saves the result under 7c9e. Its 201 response with receipt r_55 is lost when the phone switches from Wi-Fi to 4G, and the page times out. The page retries with the same key. The API looks up 7c9e, finds the saved result, makes no new charge, and returns the same 201 with receipt r_55.Checkout pagePayments APIKey storePOST, key 7c9esave 7c9e: r_55201 receipt r_55lost: timeout at 10 sretry, same key 7c9elook up 7c9efound: r_55201 receipt r_55one charge, one receipt
The retry is safe only because it carries the same Idempotency-Key. A fresh key would look like a second payment.

Stripe's API made this pattern well known, and an IETF working-group draft describes the same header. It is not yet an RFC, so the exact server behavior varies by API. The REST API design lesson covers the server side in depth: how long keys are stored, what happens when the same key arrives with a different body, and how a request still in progress is reported.

The client's rules

  • One key per intent, made once. Create it when the user starts checkout or opens the form, not inside the retry loop. A new key per attempt makes every retry look like a new payment, which is the bug you wanted to prevent.
  • Reuse it for the manual Retry button too. If the automatic retries give up and the user taps Retry, that is still the same intent. Same key.
  • Keep it across a reload if the action matters. Put it next to the draft in sessionStorage or IndexedDB, so a refresh in the middle of checkout does not create a second key.
  • Make a new key only for a new intent. After a success, or after the user changes the cart, the next Pay is a different action.
  • Disable the button while a request is in flight. A key protects against duplicates, but a second click that makes a second key would not be caught.
  • Wait and retry on "still in progress". If two attempts with the same key overlap, many APIs answer the second with 409 Conflict. That 409 is worth a retry after a short wait, unlike a 409 about the data itself.

When the API has no idempotency key

Many APIs do not support keys. Then the policy from the error table holds: a non-idempotent write does not retry automatically. On a network error or timeout, show a clear message, keep everything the user typed, and offer a Retry button. For high-stakes actions, check first: fetch the order list or the payment status, and only offer Retry if the first attempt really did not land.

Some writes can be redesigned to be naturally idempotent instead. "Set the quantity of item 42 to 2" (PUT) is safe to repeat, while "add one item 42" (POST) is not. Creating a resource under an ID the client chose, such as PUT /drafts/{uuid}, works the same way.

Check your understanding · Exercise 4

A booking form sends POST /bookings with an Idempotency-Key. The first attempt times out after 10 seconds. Which client behaviors are correct? Select all that apply.

Knowing when to stop: retry budgets and circuit breakers

Backoff and jitter decide when one request tries again. They do not decide whether all of your retries, added up, are making things worse. Two tools do: a retry budget, which limits how many retries the whole client sends, and a circuit breaker, which stops calling a service that is clearly down.

Retries multiply across layers

A request from the browser rarely goes straight to the database. It passes through an API gateway, then a service, then maybe another service. If each layer makes up to 3 attempts, one click can become 3 times 3 times 3, which is 27 calls to a database that is already failing. Every layer thought it was being careful.

A stack of layers: the browser, the API gateway, the orders service, and the database. Each of the first three makes up to 3 attempts for every request it gets. One click becomes up to 3 requests to the gateway, 9 to the service, and 27 queries to a database that is already failing. A note says that with only one layer retrying, it is 3 calls instead of 27.Browserup to 3 triessends 3API gateway3 tries for eachsends 9Orders service3 tries for eachsends 27DBone layer retries:3 calls, not 27
Every layer is being careful, and together they multiply the load by 27. Pick one layer to retry, often the one nearest the user, and make the others fail fast.

The fix is to retry in one layer only and make the others fail fast. In a web app the client is often the best place for reads, because it knows whether a person is still waiting and can show progress. If your gateway or backend already retries, the browser should retry less, or not at all. In an interview, asking "who else retries this call?" is a strong signal.

A retry budget

Even in one layer, retries add load exactly when the server is weakest. A retry budget caps that extra load. A common rule, described in Google's SRE book, is that retries may be at most about 10% of requests: when things are healthy you have plenty of room, and during an outage the client sends roughly its normal traffic plus 10%, not 4 times it.

The usual way to build it is a small token bucket. Each success adds a fraction of a token, each retry spends a whole token, and when the bucket is low, failures are reported at once without a retry. gRPC clients have this built in as retryThrottling with a tokenRatio setting, and AWS SDKs use a similar retry quota.

ts
// Retries may be about 10% of successes; at most 10 saved up
const budget = { tokens: 10, max: 10, ratio: 0.1 };

function onSuccess() {
  budget.tokens = Math.min(budget.max, budget.tokens + budget.ratio);
}

function canRetry(): boolean {
  if (budget.tokens < 1) return false; // budget spent: fail fast
  budget.tokens -= 1;
  return true;
}
One budget per tab and per backend, shared by every request. In withRetry, call canRetry() before sleeping and onSuccess() after a good response.

The circuit breaker

A circuit breaker watches every request to one service and, when too many fail, stops sending any for a while. The name comes from the switch in a fuse box: when something is wrong, it cuts the power instead of letting the wires overheat. Michael Nygard popularized it in the book "Release It!", and Martin Fowler's short article is the usual reference.

  • Closed (normal): requests go through. The breaker counts failures over a recent window, such as the last 20 calls.
  • Open: too many failed, for example 5 in a row or half of the last 20. Every call fails at once, without touching the network, for a cool-down such as 30 seconds. The UI shows "Recommendations are unavailable right now" instead of a spinner.
  • Half-open: after the cool-down, one trial request goes through. If it succeeds, the breaker closes. If it fails, the breaker opens for another cool-down, often longer.
The reporting service is:
A circuit breaker with three states: closed, open, and half-open. It is now closed, at 0 seconds. Closed moves to open after 3 failures in a row; open moves to half-open after 30 seconds; half-open moves to closed if the trial request works and back to open if it fails. So far 0 network calls were made and the user spent 0 seconds waiting.closedopenhalf-open3 failures in a rowafter 30 strial workstrial failst = 0 scalls 0, waited 0 s
The highlighted state is the breaker's state now. While it is open, loading the panel costs no network call and no waiting; after the cool-down, one trial request decides whether to close it.

The service is healthy and the breaker is closed.

    Breakers are most common between backend services, but they help in the browser too. Picture a dashboard where one of eight panels calls a reporting service that is down. Without a breaker, every refresh and route change waits for that service to time out again. With one, the next views show that panel's error at once while the other seven load normally.

    • Keep one breaker per backend or host, in a module that every component shares, not one per component.
    • Count only failures that mean "the service is unwell": network errors, timeouts, 502 to 504. A 404 or 422 says nothing about the service's health.
    • Show cached or fallback data when open, with a short note, rather than an empty panel.

    Check your understanding · Exercise 5

    Your web app retries reads up to 3 times. You learn that the API gateway also retries 3 times, and the orders service retries its database calls 3 times. The database starts failing. What should you change? Select all that apply.

    Offline: stop retrying and wait for the network

    When the device has no network at all, every retry fails in a few milliseconds, burns battery, and fills your error tracker with noise. The better move is to notice that the device is offline, pause the retry timers, tell the user, and try again when the network returns.

    The browser exposes navigator.onLine, a boolean, and fires offline and online events on window when it changes. They are supported everywhere and cost nothing. But they answer a narrower question than their names suggest.

    ValueWhat it really meansWhat to do
    falseThe device has no network connection at all (airplane mode, Wi-Fi off, cable out)Trust it: pause retries, show an offline banner
    trueThe device is connected to some network, such as a router or a cell towerDo not trust it as "the server is reachable"; it is only permission to try

    true can be wrong in many everyday ways: hotel Wi-Fi with a login page (a captive portal) that answers every request with its own page, a router whose internet link is down, a VPN that dropped, a company firewall that blocks your API, or a train connection so weak that nothing gets through. MDN says it plainly: you cannot assume that true means the browser can reach the internet.

    So the reliable signal is your own requests. If the browser says online but three requests in a row fail with network errors, treat the app as offline for practical purposes. The first request that succeeds is the real proof that you are back.

    Pausing and resuming the retry loop

    ts
    function waitForOnline(signal: AbortSignal): Promise<void> {
      if (navigator.onLine) return Promise.resolve();
      return new Promise((resolve, reject) => {
        signal.throwIfAborted();
        const done = () => { cleanup(); resolve(); };
        const stop = () => { cleanup(); reject(signal.reason); };
        const cleanup = () => {
          window.removeEventListener("online", done);
          signal.removeEventListener("abort", stop);
        };
        window.addEventListener("online", done);
        signal.addEventListener("abort", stop);
      });
    }
    
    // In withRetry, before each new attempt:
    // await waitForOnline(signal);  // does not use up an attempt while offline
    
    While offline, the loop waits for the online event instead of spending its attempts. The attempt count and the retry budget are kept for real failures.
    • Do not count offline time as failures. Failures while navigator.onLine is false should not use up attempts, spend the retry budget, or open a circuit breaker. The service did nothing wrong.
    • Resume with a little jitter. When an office's Wi-Fi comes back, hundreds of laptops fire online in the same second. A random delay of up to a second or two spreads them out.
    • Refetch when the user comes back. Many apps also refetch stale data on online and when the tab becomes visible again. TanStack Query does both by default (refetchOnReconnect, refetchOnWindowFocus), and by default pauses queries and mutations while the browser reports offline.
    • Queue writes that must not be lost. Save them, with their idempotency keys, in IndexedDB so they survive a reload, and send them in order when the network returns. The background sync lesson covers this in depth, including the Background Sync API, which only Chromium-based browsers support.

    What the user should see

    Being offline is a state of the app, not an error message. Keep showing the data you already have, mark it as possibly out of date, and put one calm banner at the top instead of an error in every panel. Buttons that need the network should say so, and actions the app can queue should say they will be sent later.

    A wireframe of an orders screen while offline. At the top, one banner: Offline, showing data from 10:42. Below it, two orders stay visible, each marked as of 10:42. A note to the driver is shown as queued, to be sent when online. The Refresh button is disabled and says it needs the network. Notes beside it say the banner is a polite status region announced once, the old data stays with its time, and the queued note keeps its idempotency key.OrdersOffline. Showing data from 10:42Order 1042: deliveredas of 10:42Order 1043: on the wayas of 10:42Note to driver: queuedRefreshneeds the networkrole=status,announced onceold data stays,with its timequeued, with itsidempotency key
    Offline is a state, not an error: one calm banner, the data you have, and clear labels for what waits for the network.

    Check your understanding · Exercise 6

    A field-inspection app is used in basements with no signal. Which statements about navigator.onLine and the online and offline events are true? Select all that apply.

    In real products, and what the user feels

    You will rarely write all of this from scratch. Data libraries and SDKs ship retry policies, and the useful skill is knowing their defaults well enough to change them on purpose.

    Defaults in common libraries

    LibraryDefault retry behaviorWhat to change
    TanStack Query (queries)3 retries in the browser, none on the server; waits of 1 s, 2 s, 4 s, capped at 30 s, with no jitterPass a retry function that skips errors that will not heal, such as 404, and a retryDelay that adds jitter
    TanStack Query (mutations)No retriesTurn on only for writes that carry an idempotency key
    Apollo Client RetryLinkUp to 5 attempts, starting near 300 ms, with jitterUse retryIf to skip errors that will not heal
    Stripe's official server librariesCan retry network failures, adding an idempotency key automatically when they do; the default retry count depends on the library and versionSet the number of retries; pass your own key for user actions
    fetch and AxiosNo retries at allWrap them in a helper like withRetry

    Note the TanStack Query row: its default delay has no randomness, and the default retry: 3 also retries a 404. Most teams pass a retry function that checks the status and a retryDelay such as (attempt) => Math.random() * Math.min(1000 * 2 ** attempt, 30_000).

    The user's side of a retry

    Retries are invisible when they work, and that is the goal. The first retry or two should be silent: the spinner keeps turning. If the wait goes past about 2 seconds, change the message to something honest like "Still loading..." or "Reconnecting...", so the user knows the app has not frozen. When the loop gives up, show an error that says what failed, keeps every field the user typed, and offers a Retry button.

    • Keep stale data on screen. A list that failed to refresh should keep its old rows with a small "Could not refresh" note, not turn into an error page.
    • Errors per panel, not per page. One failing widget should not blank the whole dashboard. This is where timeouts and circuit breakers pay off.

    Accessibility

    Screen reader users cannot see a spinner change to "Reconnecting". Put connection and loading status in a live region, an element with role="status" (which is polite: it waits for the screen reader to finish speaking). Announce changes of state, such as "You are offline", "Back online", or "Could not load orders", not every attempt.

    Do not move focus to the error or to the Retry button on your own: the user may be in the middle of a form. Make Retry a real button, and when it is disabled during a 429 cool-down, say why in text.

    Performance and security

    • Battery and data. A tight retry loop on a phone keeps the radio awake. Backoff, the offline pause, and pausing background retries in hidden tabs (check document.visibilityState) all save battery.
    • Do not retry login failures. A 401 from a login form means wrong credentials. Retrying can trip the account lockout or the server's brute-force protection.
    • Respect rate limits as a contract. A client that ignores 429 and Retry-After may get its API key or IP blocked. The limit protects everyone else on the server.
    • Keys must be unguessable. Use crypto.randomUUID(), not a counter or a timestamp, for idempotency keys, and the server should scope keys to the user who sent them.

    Using it in a system design interview

    Retries come up in almost every frontend design question, usually as "what happens if the request fails?" or "what if the API is slow?". It is rarely the main topic, so the skill is to give a complete, compact answer in a minute, and go deeper only if the interviewer asks.

    When to bring it up

    • In the API or data-fetching part of the answer, right after you list the endpoints: one sentence on timeouts and which calls retry.
    • Whenever a write costs money or cannot be undone: payments, bookings, sending a message. Say "idempotency key" here, unprompted.
    • When the design has many clients reconnecting at once: chat, live dashboards, polling. Say "backoff with jitter" and mention the thundering herd.

    A one-minute answer

    Here is a compact version you can adapt: "Every request has a timeout, set from p99 latency, with AbortSignal.timeout merged with the component's cancel signal. Reads retry up to 3 times on network errors, 408, 429, and 502 to 504, with exponential backoff and full jitter, and never sooner than Retry-After. Other 4xx errors show a message, and a 500 shows one with a Retry button."

    Then the writes and the edge cases: "Writes retry only with an idempotency key made when the user starts the action. Retries pause while offline, and a circuit breaker per backend stops calls to a service that is down, so the rest of the page stays fast."

    What to draw: the retry state machine from this lesson (send, classify, wait, done or failed) next to the client box, or a short sequence of a POST with an Idempotency-Key that times out and is retried. Either takes 30 seconds and shows you think about failure as part of the design.

    How deep to go, by level

    LevelWhat a strong answer covers
    JuniorRequests can fail; show loading and error states with a Retry button; do not retry a 404
    Mid-levelTimeouts with AbortSignal; which statuses to retry; exponential backoff with a cap; idempotency keys for payments
    SeniorJitter and the thundering herd; Retry-After; total deadlines; retrying in one layer only; offline pausing; library defaults and how to change them
    StaffRetry budgets and circuit breakers across the fleet; server-controlled backoff during incidents; metrics and alerts on retries and give-ups; the contract between client and API teams

    Mistakes that cost points

    • Saying "we retry on failure" without saying which failures, how many times, or how long apart.
    • Retrying a payment POST with no idempotency key, or making a new key for each retry.
    • Fixed retry intervals for thousands of clients, with no mention of jitter.
    • No timeout, so the retry logic never runs on a hung request.

    Think first

    An interviewer asks: "Your live sports scores page polls every 10 seconds. The scores API goes down for 2 minutes during the final. What does your client do, and what does the API see when it comes back?" Sketch your answer.

    Summary

    • Every user-facing request needs a timeout: fetch has none. Use AbortSignal.timeout, merged with the cancel signal through AbortSignal.any, and cap the total time across all attempts.
    • Retry only what can heal: network errors, timeouts, 408, 429, 502, 503, 504. Other 4xx get a clear error message, a 500 gets one with a Retry button, and a user's cancel is never retried.
    • Retry automatically only when repeating is safe: reads, idempotent methods, or writes with an idempotency key made once per user intent and reused on every retry.
    • Space retries with exponential backoff and a cap, and add full jitter so clients that failed together do not come back together. Never retry sooner than Retry-After.
    • Retry in one layer, cap attempts at 3 or 4, and for large fleets add a retry budget and a circuit breaker so retries cannot bury a struggling server.
    • navigator.onLine === false reliably means offline, but true does not mean reachable. Pause retries while offline, do not count that time as failures, and resume on online with a little jitter.
    • Retries should be invisible when they work and honest when they do not: keep stale data and user input, announce state changes in a polite live region, and log every give-up.

    In the interview

    Questions interviewers ask about this. Answer each out loud first, then compare.

    Question 1

    A checkout POST times out. Should the client retry it?

    Question 2

    Explain exponential backoff with jitter, and why you need both parts.

    Question 3

    Which errors would your client retry automatically, and which not?

    Question 4

    A dashboard calls 8 services. One is down, and the whole page takes 40 seconds to load. What do you change?

    Question 5

    How do you detect that the user is offline, and what does your retry logic do about it?

    Practice it

    These design problems lean on retries, timeouts, and backoff.

    References

    Read next