SnippetJavaScript

Retry an async call with backoff

Retries a failing async function with exponential delay and a capped ceiling, so a flaky call gets a few spaced-out chances, not a flood.

Last updated

Snippetjavascript
async function retryWithBackoff(fn, { retries = 3, baseMs = 200, maxMs = 5000 } = {}) {
  for (let attempt = 0; ; attempt++) {
    try {
      return await fn();
    } catch (err) {
      if (attempt >= retries) throw err;
      const delay = Math.min(baseMs * 2 ** attempt, maxMs);
      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }
}

// await retryWithBackoff(() => fetch(url).then((r) => r.json()));

How it works

The loop has no upper bound of its own — it relies on the attempt >= retries check inside the catch block to eventually rethrow, so the LAST failure's error is always the one that surfaces to the caller, not a generic "gave up" message. That preserves whatever diagnostic information fn's own error carried.

baseMs * 2 ** attempt is the exponential part: delays go 200ms, 400ms, 800ms, 1600ms... roughly doubling each retry so a struggling dependency gets increasing breathing room instead of being hammered at a fixed interval. Math.min against maxMs stops that growth from reaching minutes-long waits after enough attempts.

Edge cases to know

  • →Only retry idempotent operations — a GET, or a write with its own dedupe key. Retrying a plain POST (charge a card, send an email) on a timeout can execute it twice if the first attempt actually succeeded server-side and only the response was lost.
  • →This has no jitter: every caller retrying the same failing dependency backs off on the exact same schedule, which can cause synchronized retry storms under load. Real production backoff usually adds a random jitter factor to the delay.
  • →There's no overall timeout — with the defaults, 3 retries plus their delays could still take several seconds beyond the calls themselves before giving up.

Part by part

for (let attempt = 0; ; attempt++)
A deliberately conditionless loop. Exit happens by returning on success or rethrowing once retries run out, which keeps both endings explicit.
return await fn()
The await matters: without it the promise returns before the catch can see a rejection, so failures escape the retry entirely.
if (attempt >= retries) throw err
Rethrows the original error rather than a wrapper, so the caller sees what actually went wrong on the final attempt.
baseMs * 2 ** attempt
Exponential growth — 200ms, 400ms, 800ms. Backing off gives a struggling service room to recover instead of adding to the load.
Math.min
Caps the delay, so a high retry count cannot produce a wait measured in minutes.

Common mistakes

  • const delay = baseMs * attempt;

    Linear growth backs off far too slowly under real load — 200ms, 400ms, 600ms barely spreads the attempts. Doubling is what actually relieves a saturated service.

    const delay = Math.min(baseMs * 2 ** attempt, maxMs);
  • return fn();

    Without await, the promise is returned immediately and a rejection never reaches the catch block. The retry logic is present, compiles, and never runs.

  • Retrying every error the same way

    A 400 or a 404 will fail identically every time, so retrying wastes the caller's time and the server's. Retry timeouts, connection failures and 5xx responses; fail fast on anything the client got wrong.

Questions

Should I add jitter?

Yes, for anything with many clients. Without it every caller that failed at the same moment retries at the same moment, and the recovering service is hit by a synchronised wave. Randomising the delay spreads them out.

Which errors are worth retrying?

Transient ones: network failures, timeouts, 429 and 5xx. A 4xx other than 429 means the request itself is wrong, so repeating it unchanged cannot succeed.

How many retries is sensible?

Three is a good default. Beyond that the total wait becomes noticeable to whoever is waiting, and a service that has failed four times is usually properly down rather than briefly busy.

Does it respect a Retry-After header?

No, and it should where one is present — a 429 often tells you exactly how long to wait. Read the header and prefer it over the computed delay when the server has given you a number.

Related in Snippets