devkult_
tools29converters34
home/blog/retries-exponential-backoff
httpapireliability

Retrying HTTP Requests: Exponential Backoff, Jitter and Idempotency

Networks fail, servers restart, rate limits kick in. A client that retries handles all of that gracefully — and a client that retries badly turns a small outage into a big one, as thousands of clients hammer a struggling server in lockstep. Good retries come down to four decisions: what to retry, how long to wait, how many times, and whether retrying is safe at all.

What to retry

Outcome Retry? Why
Network error or timeout Yes The request may never have arrived
408 Request Timeout Yes The server gave up waiting for the request
429 Too Many Requests Yes, after Retry-After You're rate limited; waiting is the fix
502, 503, 504 Yes, with backoff Usually temporary infrastructure trouble
500 Internal Server Error Maybe Sometimes transient, often a bug that will fail again
Other 4xx (400, 401, 403, 404, 422…) No The request itself is wrong; sending it again won't help

Is it safe to retry?

A retry is only safe if doing the operation twice has the same effect as doing it once. HTTP defines GET, HEAD, OPTIONS, PUT and DELETE as idempotent: repeating a PUT /users/42 leaves the same result. POST and PATCH are not — retrying POST /payments after a timeout can charge a card twice, because a 504 doesn't tell you whether the first attempt finished.

The standard fix is an idempotency key: the client generates a unique id per logical operation and sends it with every attempt.

POST /payments HTTP/1.1
Idempotency-Key: 7f9c2ba4-e88f-4c5d-9a1e-3b8f6d2c4a10

The server stores the result under that key; a retry with the same key gets the stored result instead of a second charge. Stripe popularised the pattern, and an IETF draft standardises the Idempotency-Key header.

How long to wait: exponential backoff with jitter

Retrying immediately just adds load at the worst moment. Exponential backoff doubles the wait after each failure — 0.5 s, 1 s, 2 s, 4 s — up to a cap. On its own, though, it keeps clients synchronised: everyone who failed together retries together. Jitter randomises each wait to spread them out. The simplest effective version, called full jitter, picks a random delay between zero and the exponential limit.

And if the server tells you when to come back — Retry-After on a 429 or 503 — believe it.

A complete example

const RETRYABLE = new Set([408, 425, 429, 500, 502, 503, 504]);

async function fetchWithRetry(url, options = {}, { retries = 4, baseMs = 500, capMs = 10_000 } = {}) {
  for (let attempt = 0; ; attempt++) {
    let res;
    try {
      // A fresh 10-second timeout for each attempt
      res = await fetch(url, { ...options, signal: AbortSignal.timeout(10_000) });
      if (!RETRYABLE.has(res.status) || attempt === retries) return res;
    } catch (err) {
      if (attempt === retries) throw err; // network error or timeout, out of retries
    }
    await new Promise((resolve) => setTimeout(resolve, delayFor(res, attempt, baseMs, capMs)));
  }
}

function delayFor(res, attempt, baseMs, capMs) {
  const header = res?.headers.get("Retry-After");
  if (header) {
    const seconds = Number(header);
    const ms = Number.isNaN(seconds) ? Date.parse(header) - Date.now() : seconds * 1000;
    if (ms > 0) return ms;
  }
  // Full jitter: random wait between 0 and the exponential limit
  return Math.random() * Math.min(capMs, baseMs * 2 ** attempt);
}

A few details are doing real work here:

  • Retry-After comes in two forms — seconds (120) or an HTTP date — and both are handled.
  • Each attempt gets its own timeout, so one hung connection can't eat the whole retry budget. (This replaces any signal you passed in; combine them with AbortSignal.any() if you need both.)
  • The last failed response is returned rather than thrown, so callers still see the real status.
  • Only use this for POST if you also send an idempotency key.

How many times

Three to five attempts covers almost all transient failures. More than that mostly prolongs an outage. Two system-level rules matter even more:

  • Retry at one layer only. If the browser, your API gateway and your backend each retry three times, one failure becomes 27 requests to the database.
  • Stop when things are clearly broken. A circuit breaker fails fast for a while after repeated errors, giving the failing service room to recover instead of burying it.

Server side: make retries cheap

If you run the API, you can make every client's retry logic work better:

  • Send Retry-After with 429 and 503 responses.
  • Return 429 for rate limits, not 500 or 403, so clients know waiting will help.
  • Accept idempotency keys on POST endpoints that create things or move money.

For what each status code means from the server's side, see the HTTP status code reference, and for telling gateway errors apart, 502 vs 503 vs 504.

More from the blog