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?
answer
- async function around a for loop
- return await, not return
- promise-wrapped setTimeout for the pause
- remember the last error
- throw after the loop ends
basics
~20 sWrite 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 sThe 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 linesconst 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)); // 3go deeper
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.
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.
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.
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