InterviewsVector
intermediateCommon5 min read · Updated Jul 18, 2026

Implement Retry with Exponential Backoff and Jitter

Implement an async retry wrapper in JavaScript with exponential backoff, full jitter, retryable-error filtering, and AbortSignal support — the way production clients do it.


The problem

Implement retry(fn, options): call the async function; if it rejects, wait and try again, up to N attempts, with exponentially increasing delays.

The naive loop is five lines. What interviewers are actually probing is whether you've operated retries in production, because naive retry is actively harmful: it retries errors that can never succeed, and it synchronizes clients into thundering herds that finish off an already-struggling service. Every real client library (AWS SDKs, gRPC, fetch-retry) converges on the same three refinements — selective retry, exponential backoff, jitter — and this question checks whether you know why each exists.

Implementation

async function retry(fn, {
  retries = 3,          // retry attempts AFTER the first try (4 calls total)
  baseDelay = 200,      // ms
  maxDelay = 10_000,    // cap — backoff must not grow unbounded
  shouldRetry = () => true, // which errors are worth retrying
  signal,               // optional AbortSignal
} = {}) {
  for (let attempt = 0; ; attempt++) {
    try {
      return await fn(attempt); // pass attempt for logging/metrics
    } catch (error) {
      const outOfAttempts = attempt >= retries;
      if (outOfAttempts || !shouldRetry(error) || signal?.aborted) {
        throw error; // rethrow the LAST error, unchanged
      }
      // Exponential backoff with FULL JITTER:
      // random point in [0, min(maxDelay, base * 2^attempt))
      const cap = Math.min(maxDelay, baseDelay * 2 ** attempt);
      const delay = Math.random() * cap;
      await sleep(delay, signal);
    }
  }
}
 
function sleep(ms, signal) {
  return new Promise((resolve, reject) => {
    const id = setTimeout(resolve, ms);
    signal?.addEventListener("abort", () => {
      clearTimeout(id);
      reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
    }, { once: true });
  });
}

Because fn is called inside try within an async function, synchronous throws are captured too — the recursive setTimeout(attempt) formulations lose sync throws on retry attempts (the error escapes the timer callback and becomes an uncaught exception). The async/await loop gets this right for free; noting it is a strong detail.

Usage — where the design decisions live

const data = await retry(
  () => fetch(url, { signal }).then((res) => {
    if (!res.ok) {
      const err = new Error(`HTTP ${res.status}`);
      err.status = res.status;
      throw err;
    }
    return res.json();
  }),
  {
    retries: 3,
    shouldRetry: (err) =>
      err.name === "TypeError" ||          // network failure
      err.status === 429 ||                // rate limited
      (err.status >= 500 && err.status <= 599), // server errors
    signal,
  }
);

Why each refinement exists

Selective retry (shouldRetry) — a 404 will be a 404 all five times; a 400 means your request is malformed; a ReferenceError means your code is broken. Retrying them adds latency and masks bugs. The rule of thumb worth reciting: retry transient failures (network drop, 429, 5xx, timeouts), never deterministic ones (4xx except 429, programming errors). Bonus: an idempotency caveat — retrying a non-idempotent POST can double-charge; real systems pair retries with idempotency keys.

Exponential backoff — a failing service needs decreasing pressure, not constant-interval hammering. Doubling (200ms, 400ms, 800ms…) backs off fast while keeping early retries cheap. The maxDelay cap prevents attempt 10 from waiting 17 minutes.

Jitter — if 1,000 clients fail at the same instant (service blip), fixed backoff makes them all retry at the same instants forever — synchronized waves that keep re-killing the recovering service. Randomizing the delay decorrelates them. Full jitter (random(0, cap), used above) is the variant AWS's classic analysis found best; naming the thundering-herd problem it solves is the senior signal in this entire question.

Edge cases interviewers probe

  • Attempt accountingretries: 3 means 3 retries, 4 calls total. Off-by-one here silently changes behavior; state your convention.
  • Which error to throw — the last one (freshest information). Some libraries aggregate all attempts (AggregateError) — a fine extension if you say what you're doing.
  • Sync throws from fn — handled by the try/await shape (see above); the classic recursive-setTimeout version crashes.
  • Cancellation — a retry loop can hold a request alive for minutes; without signal support, unmounted components and abandoned requests keep retrying. The sleep must also be abortable — aborting mid-delay is the case everyone forgets. See AbortController.
  • Retry-After headers — 429/503 responses often tell you how long to wait; honoring the server beats your own schedule. Great "productionize this" answer.

Common mistakes

  • Retrying everything, including 4xx and TypeErrors from your own bugs.
  • No jitter — the thundering-herd interview point missed entirely.
  • No cap on backoff growth.
  • Recursive setTimeout structure losing sync throws and complicating cancellation.
  • Swallowing the original error and throwing new Error("retries exhausted") — destroys the stack and status code the caller needs for handling.
  • Forgetting that retried POSTs need idempotency guarantees.

Follow-up questions

  • "Add a total-time budget, not just an attempt count." — track a deadline (Date.now() + budget) and bail when the next delay would cross it; deadlines compose better than counts across call stacks.
  • "How does this interact with the concurrency pool?" — retry inside the pooled task, so a retrying task keeps holding its slot and can't stampede; retrying outside the pool multiplies effective concurrency.
  • "Implement retry as a decorator/higher-order function."withRetry(fn, opts) returning a wrapped function — same closure pattern as memoize.
  • "What's circuit breaking, and when does retry stop being the answer?" — after enough consecutive failures, stop calling entirely for a cooldown window; retries handle blips, breakers handle outages. Knowing where one ends and the other begins is a system-design bridge interviewers love.
  • "How would you test this?" — fake timers for the delays, an fn stub failing K times then succeeding, assertions on call counts and on delay distribution bounds (jitter makes exact-value asserts wrong — assert the range).

  • Batching Promises

    intermediate

    Process a large list of async tasks in fixed-size sequential batches to protect downstream services.

  • Run async tasks one after another and understand why reduce-with-promises works.

  • Run N async tasks with at most K in flight — the production-style p-limit pattern, including ordering and failure trade-offs.