10LOC
Node.jsintermediate

Retrying a flaky async call with exponential backoff

Published April 23, 2027

export const retryWithBackoff = async <T>(fn: () => Promise<T>, maxAttempts = 5, baseDelayMs = 200): Promise<T> => {
  for (let attempt = 1; ; attempt++) {
    try {
      return await fn();
    } catch (err) {
      if (attempt >= maxAttempts) throw err;
      const delay = baseDelayMs * 2 ** (attempt - 1) + Math.random() * baseDelayMs;
      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }
};

What

retryWithBackoff(fn) calls fn, and if it throws, waits and tries again — up to maxAttempts times — with the wait doubling after each failed attempt and a random amount of jitter added on top.

Why it matters

Retrying immediately after a failure is often worse than not retrying at all: if a downstream service is struggling under load, every client retrying instantly just adds another wave of load on top of the one that caused the failure. Fixed-delay retries ("wait 1 second, try again") are better, but every client that failed at the same moment still retries in lockstep, which recreates the same thundering-herd problem one second later.

Exponential backoff spaces out retries so a client that keeps failing backs off further each time, giving whatever's downstream room to recover instead of getting hit with sustained retry traffic. Jitter — the Math.random() * baseDelayMs term — solves the lockstep problem on top of that: without it, every client that started failing at the same instant still retries at the same instants as each other, just further apart. Randomizing the delay spreads those retries out instead of leaving them synchronized.

How it works

  • for (let attempt = 1; ; attempt++) is an intentionally condition-less loop — the only way out is a return (success) or throw (attempts exhausted) inside the body, so TypeScript can tell the function always produces a Promise<T> on every path without an unreachable statement after the loop.
  • return await fn() is the success path; the await matters here, since without it a rejected promise returned directly wouldn't be caught by this function's own try/catch.
  • if (attempt >= maxAttempts) throw err gives up only once every attempt has been used, re-throwing the last failure rather than swallowing it.
  • The delay formula, baseDelayMs * 2 ** (attempt - 1) + jitter, gives attempt 1's failure a ~200-400ms wait, attempt 2's a ~400-600ms wait, attempt 3's a ~800-1000ms wait, and so on — doubling the base each time, jitter layered on top.

Gotchas

  • This retries any thrown error, including ones that will never succeed no matter how many times you retry (bad input, a 400 response). Only wrap calls where the failure is plausibly transient — a network blip, a rate limit, a lock conflict — not all failures.
  • There's no cap on the maximum delay here; after enough attempts, 2 ** attempt grows unbounded. For a maxAttempts much larger than 5-6, add a Math.min(delay, MAX_DELAY_MS) ceiling.
  • fn is called fresh on every attempt with no memoization — if it has side effects (an outbound write, not just a read), make sure retrying it is actually safe (idempotent) before wrapping it in this.

Related

  • Token-bucket rate limiting — the server-side complement: this decides how a caller reacts to failure; a rate limiter decides whether to let the call through in the first place.
  • AbortSignal.timeout() — often combined with retry logic so each individual attempt has its own timeout, separate from the overall retry budget.