skip to content

A JavaScript retry helper is written as `retry(operation, 3)`. Why must `operation` be a function that returns a promise rather than an already-created promise, and what exactly happens if someone calls `retry(loadUser(1), 3)`?

level: middleimportance: should knowfreq 45%

answer

  1. promises are eager, not lazy
  2. settles once, then frozen
  3. no restart on a promise
  4. pass a thunk, not a value
  5. one fresh call per attempt

basics

~20 s

A promise represents work that already started and settles exactly once, so awaiting it again never re-runs anything. Passing loadUser(1) gives the helper one settled rejection to await three times: it burns the backoff delays and calls the dependency once.

solid answer

~50 s

Creating a promise in JavaScript is eager: `loadUser(1)` fires the request there and then, and the resulting promise settles once and keeps that outcome forever. Awaiting it a second or third time just replays the stored result — there is no `restart()` on a promise, and there cannot be, because the promise never knew what produced it. So `retry(loadUser(1), 3)` loops over one dead promise, sleeps through the full backoff schedule, and rethrows the original error while the dependency saw exactly one call. The wrapper therefore has to take a thunk — `retry(() => loadUser(1), 3)` — so each attempt invokes the function fresh and produces a new promise with a new request behind it. The general rule: wrappers that re-run work take a function, wrappers that merely observe an operation can take a promise.

code

javascript · 13 lines
javascript
let calls = 0;
function flaky() {
  calls += 1;
  return Promise.reject(new Error('boom'));
}

const started = flaky();     // work happens here, exactly once
started.catch(() => {});
started.catch(() => {});     // replays the stored rejection

flaky().catch(() => {});     // a fresh call, a fresh promise

setTimeout(() => console.log(calls), 0); // 2, not 3

go deeper

for a junior

Remember that calling an async function starts the work immediately and the promise it returns settles once. Retrying therefore means calling the function again, which is why the helper takes () => doWork() rather than doWork().

for a middle

Explain the mechanics: eager creation, single settlement, and a stored outcome mean a second await replays the same result. Walk through what retry(loadUser(1), 3) does attempt by attempt and why only the failure path exposes it.

for a senior

Show the design rule — wrappers that re-run work take a factory, wrappers that only observe take a promise — and apply it: per-attempt timeouts belong inside the loop, an overall timeout can wrap from outside. Catch the hoisted-promise variant in code review.

for a principal

Own the convention across a codebase: one retry helper with a factory-typed parameter, a naming rule that makes factory-versus-promise obvious at every call site, and a lint or type contract so the defect cannot be reintroduced service by service.

## Promises are eager, and they settle once In some systems an async value is a *description* of work that runs when someone subscribes. JavaScript is not like that. The moment you call `loadUser(1)`, the function body runs and the request is on its way; the promise you get back is a handle on work already in progress. And a promise transitions from pending to fulfilled or rejected exactly once — after that its state and value are frozen, and attaching another handler merely schedules a callback with the stored outcome. ```js let calls = 0; function flaky() { calls += 1; return Promise.reject(new Error('boom')); } const p = flaky(); p.catch(() => {}); p.catch(() => {}); // same stored rejection, no second call setTimeout(() => console.log(calls), 0); // 1 ``` Those two facts together mean a promise is not a retryable unit of work. ## What retry(loadUser(1), 3) does concretely The argument is evaluated before `retry` is ever entered, so exactly one request goes out. Inside the helper, attempt 0 awaits the promise and gets a rejection; the catch sleeps for the backoff delay; attempt 1 awaits the *same* promise and gets the *same* rejection instantly; attempt 2 likewise. The helper then throws the original error. The damage is worse than “it did not help”, because it is invisible: - The dependency was called once, so client-side attempt metrics are wrong. - The failure now takes the whole backoff schedule to surface, adding latency for zero benefit. - If the promise happens to *fulfil*, the helper returns instantly and everything looks fine — so the defect only manifests on the failure path, which is the path you built the helper for. ## The thunk fixes it ```js await retry(() => loadUser(1), { attempts: 3 }); ``` The arrow function is a *thunk*: a zero-argument function that defers the call. `retry` invokes it once per attempt, so each attempt produces a brand-new promise backed by a brand-new request. Closing over the argument keeps the call site readable, and the helper can pass the attempt index back in: ```js async function retry(fn, { attempts = 3 } = {}) { for (let attempt = 0; attempt < attempts; attempt++) { try { return await fn(attempt); } catch (err) { /* back off, then loop */ } } } ``` This is unrelated to whether the callback is declared `async`. Both `() => loadUser(1)` and `async () => loadUser(1)` defer the call correctly; what matters is only that the call happens *inside* the function body, on every invocation. ## The API-design rule this generalises to Ask of any wrapper: does it need to *run* the work, or merely *observe* it? - Wrappers that re-run or multiply the work — retry, hedging by racing a second copy, per-attempt timeouts, a breaker that sends probe requests — must take a function. - Wrappers that only observe an existing operation — an overall raced timeout, a logging or metrics decorator, combinators such as `Promise.all` and `Promise.allSettled` — can take promises, because they never need a second run. That is also why a per-attempt timeout has to live *inside* the retry helper, wrapping `fn()` on each pass, rather than wrapping the whole `retry(...)` call from outside. From outside you can only bound the total elapsed time; you cannot cut short one stuck attempt, so a single hung call consumes the entire budget. ## The trap that survives the fix Even with a thunk, hoisting the call out of the arrow reintroduces the bug: ```js const pending = loadUser(1); // started here, once await retry(() => pending); // still one promise, three awaits ``` The give-away in review is a promise-valued variable or parameter where a factory was expected. If a helper's parameter is named `promise` and the helper is supposed to re-run the work, that is a defect rather than a naming preference — some codebases make it explicit by calling the parameter `factory` or documenting it as `() => Promise`.

  • If the already-created promise happens to fulfil, does the bug show up at all?
    No, and that is what makes it dangerous. On the success path the helper returns the value on attempt 0 and everything looks correct. The defect appears only on failure — exactly the path the helper exists for — where it burns the full backoff schedule while the dependency was called once.
  • Which async wrappers can legitimately take a promise rather than a function?
    Ones that only observe an operation and never need a second run: an overall raced timeout, a logging or metrics decorator, and combinators like `Promise.all` and `Promise.allSettled`. Anything that re-runs or multiplies the work — retry, hedging a duplicate request, per-attempt timeouts — must take a function so it can produce a fresh promise each time.
  • Where does a per-attempt timeout belong in a retry helper?
    Inside the loop, wrapping each `fn()` call, so every attempt gets its own deadline and a hung attempt gives way to the next one. Wrapping the outer `retry(...)` call can only bound total elapsed time; it cannot cut short an individual attempt, so one stuck call consumes the whole budget and no retry ever happens.

A promise is a receipt for an order already placed, not the menu — you can read the receipt as often as you like, but ordering again means going back to the counter.

saying these in an interview costs you the question

  • Thinks awaiting a promise again re-runs the underlying work
  • Believes a promise is lazy until it is awaited
  • Says a rejected promise can be reset or restarted
  • Names a retry helper's parameter promise instead of a factory
  • Hoists the call out of the thunk and keeps one shared promise

context