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.

Related in Snippets