skip to content

Write a JavaScript `retry(fn, { attempts, baseDelay })` helper that calls an async function up to N times with growing delays between attempts. What makes the delay and the sequencing actually work, and how does the final failure reach the caller?

level: middleimportance: must knowfreq 60%

answer

  1. async function around a for loop
  2. return await, not return
  3. promise-wrapped setTimeout for the pause
  4. remember the last error
  5. throw after the loop ends

basics

~20 s

Write an async function around a for loop: try { return await fn(); }, and in the catch remember the error, await a promise-wrapped setTimeout for the growing delay, then loop. After the last attempt, throw the remembered error so total failure is not swallowed.

solid answer

~50 s

The shape is an `async function` around a `for` loop. Inside the loop I write `return await fn()` — the `await` is what routes a rejection into the local `catch` instead of handing a pending promise back to the caller. In the `catch` I store the error, and if this was not the last attempt I `await sleep(delay)` where `sleep = (ms) => new Promise((r) => setTimeout(r, ms))`; awaiting is what actually pauses the loop, because the async function suspends and resumes only when the timer resolves. The delay grows from the attempt index, typically `baseDelay * 2 ** attempt`, capped and mixed with a random component. After the loop I `throw lastError` — the most common bug is falling out of the loop with no throw, so the helper quietly fulfils with `undefined` and the caller treats total failure as success. And the helper must take a function, not a promise, so every attempt is a fresh call.

code

javascript · 20 lines
javascript
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function retry(fn, { attempts = 3, baseDelay = 50, maxDelay = 2000 } = {}) {
  let lastError;
  for (let attempt = 0; attempt < attempts; attempt++) {
    try {
      return await fn(attempt);
    } catch (err) {
      lastError = err;
      if (attempt === attempts - 1) break;
      const target = Math.min(maxDelay, baseDelay * 2 ** attempt);
      await sleep(target / 2 + Math.random() * (target / 2));
    }
  }
  throw lastError;
}

let calls = 0;
retry(() => (++calls < 3 ? Promise.reject(new Error('flaky')) : Promise.resolve(calls)))
  .then((value) => console.log('succeeded on attempt', value)); // 3

go deeper

for a junior

Be able to write the loop with try/catch, await a promise-wrapped setTimeout between attempts, and remember to throw after the last failure instead of falling off the end of the function.

for a middle

Explain why return await fn() is required for the local catch to see the rejection, why awaiting a timer promise is what actually sequences the attempts, and how the delay is computed from the attempt index rather than hard-coded.

for a senior

Demonstrate production judgment: a predicate so only genuinely retryable failures are retried, a capped delay, and awareness that N attempts with growing backoff can blow through the caller's deadline. Be ready to say when you would not retry at all.

for a principal

Own the cross-cutting story — one shared helper instead of per-call-site loops, a consistent error shape so callers can tell exhausted retries from a hard failure, and how retry policy interacts with the deadline and load budgets that many clients share.

## The skeleton ```js const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); async function retry(fn, { attempts = 3, baseDelay = 100 } = {}) { let lastError; for (let attempt = 0; attempt < attempts; attempt++) { try { return await fn(attempt); } catch (err) { lastError = err; if (attempt === attempts - 1) break; const delay = baseDelay * 2 ** attempt; await sleep(delay / 2 + Math.random() * (delay / 2)); } } throw lastError; } ``` Every line there is doing a specific job, and an interviewer asking you to write it live is checking whether you know which. ## Why `return await fn()` and not `return fn()` Inside a `try`, `return fn()` hands the pending promise straight back to the caller — the function has already returned by the time the promise settles, so no `catch` is in scope for the rejection and the retry never happens. `await` suspends the async function until the promise settles and rethrows the rejection *inside* the try block, which is the only way the local `catch` sees it. On the success path the `await` costs one extra microtask tick and nothing else. ## Why the loop actually pauses `sleep` is a promise that resolves from a `setTimeout` callback. `await sleep(delay)` suspends the async function; the rest of the loop body becomes a continuation that resumes after the timer fires. Nothing blocks — the runtime is free to do other work in between, which is exactly why you cannot write the delay as a busy `while (Date.now() - start < ms) {}` loop. That would monopolise the single thread, so the very timer you are waiting on could never fire. Sequencing follows from the same property: because each iteration awaits, attempt *n+1* cannot start before attempt *n* has settled and its delay has elapsed. ## Growing the delay `baseDelay * 2 ** attempt` gives 100, 200, 400 ms for attempts 0, 1, 2 (`**` is the exponentiation operator, standard since ES2016). Adding a random component matters when many clients retry the same failing dependency at once, because identical schedules produce identical retry waves; in JavaScript that is just `Math.random()` folded into the computed delay. Cap it with `Math.min(maxDelay, ...)` so attempt 10 does not sleep for a quarter of an hour. ## Surfacing the failure The classic broken version: ```js async function retry(fn, attempts = 3) { for (let i = 0; i < attempts; i++) { try { return await fn(); } catch {} } } ``` When every attempt fails, control falls out of the loop, the async function returns normally, and its promise fulfils with `undefined`. The caller's `await` succeeds and something downstream crashes later on a value it never expected — far harder to diagnose than the original failure. Keep the last error in a variable and `throw` it after the loop. (An optional-catch-binding `catch {}` with no parameter is legal since ES2019, but here you want the binding precisely so you can keep the error.) A related subtlety: an unconditional `catch` retries *everything*, including a `TypeError` from a typo in your own callback. A programming error is not transient, so retrying it merely delays the crash by three backoffs. Give the helper a `shouldRetry(err)` predicate and rethrow immediately when it returns false. ## It must take a function, not a promise `retry(loadUser(1))` is broken: the call already happened, and a single promise settles once, so every “attempt” awaits the same stored rejection. `retry(() => loadUser(1))` passes a thunk so each attempt makes a fresh call. Passing the index in as `fn(attempt)` is a nice touch — the callback can log which attempt it is on, or widen its own timeout. ## What the helper still does not do Retrying is only safe when re-running the operation is acceptable, and the helper cannot know that; the caller decides. The wrapper also has no notion of a total budget — three attempts with growing delays can easily outlive the deadline the caller cares about — so a production helper usually takes an overall deadline or a signal alongside the attempt count, and checks it both before each attempt and during the sleep.

  • What does the helper return if every attempt fails and you forget the trailing throw?
    It fulfils with `undefined`. Control falls out of the `for` loop, the async function returns normally, and its promise resolves — so the caller's `await` succeeds and total failure is indistinguishable from success. The bug surfaces far away, when something downstream reads a property of `undefined`. Keep the last error and throw it after the loop.
  • Why can the delay not be implemented as a busy-wait loop on Date.now()?
    JavaScript runs your code on a single thread with run-to-completion semantics. A spin loop never returns to the runtime, so no timer callback, no I/O completion and no other task can run while it spins — including the work you are waiting for. `await new Promise((r) => setTimeout(r, ms))` suspends the async function instead and lets everything else proceed.
  • Should the helper retry every error it catches?
    No. An unconditional catch retries programming errors — a `TypeError` from a bad property access, a validation failure — which cannot succeed on attempt two and only delay the eventual crash. Take a `shouldRetry(err)` predicate, keep its default conservative, and rethrow immediately when it returns false so the real error reaches the caller fast.
  • How would you give a retry helper an overall deadline rather than just an attempt count?
    Compute an absolute end time once at entry, then check it in two places: before starting each attempt, and when choosing the sleep, so the helper never sleeps past the deadline. Without that check a helper can burn the caller's entire budget inside a backoff delay and return a timeout the caller could have had seconds earlier.

saying these in an interview costs you the question

  • Writes return fn() inside try, so rejections never reach the catch
  • Falls out of the loop with no throw, resolving undefined
  • Implements the delay as a busy-wait on Date.now()
  • Passes retry(loadUser()) instead of a function
  • Retries every error including TypeErrors from its own code

context