Asynchronous and Event-Driven Programming Questions
The programming model of a single asynchronous runtime: callbacks, promises and futures, async/await, event loops and task/microtask scheduling (including microtask starvation and where rendering fits), reactive streams and back-pressure, and how ordering, cancellation, timeouts, retries and error propagation behave across JavaScript, Node.js, mobile coroutine or async runtimes, and a comparison with Python asyncio and Java futures. Includes the async mechanics of a data fetch such as parallel versus sequential requests, partial failure, stale or out-of-order responses, abort, debounced live search, single-flight token refresh, and bounded concurrency; wrapping callback APIs as promises, streaming work through async consumers, moving heavy work off the main thread and back, and diagnosing unhandled rejections, race conditions and leaked subscriptions. Excludes message-queue and pub/sub system design, server throughput and thread-pool tuning, per-language threading and memory models, rebuilding standard APIs from scratch, and fetch caching or state architecture.
What is a promise and what states can it be in? Walk through what happens when you attach a handler before it settles and after it settles, why a handler never runs synchronously, and what a thenable is.
Sample Answer
Direct answer
A promise is an object that stands for the eventual result of an asynchronous operation. It starts pending and settles exactly once, either fulfilled (with a value) or rejected (with a reason, usually an Error). After it settles it never changes. You attach handlers with .then, .catch and .finally. A handler attached before the promise settles waits in the promise's reaction list (the promise's internal list of handlers waiting for it to settle); a handler attached after it settled is queued straight away. Either way the handler never runs synchronously: the engine always runs it later as a microtask, so a caller never sees a callback fire before the line that registered it has finished. A thenable is any object with a then method; resolve(), Promise.resolve() and await adopt it (take over its outcome, so the new promise settles however the thenable settles) by calling its then, which is how different promise libraries interoperate.
States and the one-way rule
| State | Meaning | Can change to |
|---|---|---|
| pending | Work not finished | fulfilled or rejected |
| fulfilled | Finished with a value | nothing (final) |
| rejected | Failed with a reason | nothing (final) |
"Settled" means fulfilled or rejected. Calling resolve or reject a second time has no effect, which the example below demonstrates.
Handlers before and after settling, and why they are never synchronous
The program below puts every case in one run and prints the order.
const out = [];
const log = (m) => out.push(m);
// 1. Handler attached after the promise already settled: it still runs later, never inline.
const settled = Promise.resolve('value');
log('sync: before then()');
settled.then((v) => log('handler on already-settled promise got ' + v));
log('sync: after then()');
// 2. Handler attached before settlement: queued until resolve is called.
let resolveLater;
const pending = new Promise((res) => { resolveLater = res; });
pending.then((v) => log('handler attached while pending got ' + v));
log('sync: resolving now, handler has not run');
resolveLater('later');
// 3. A thenable: any object with a then method is adopted by resolve / await.
const thenable = { then(onFulfilled) { log('then() of the thenable called'); onFulfilled('from thenable'); } };
Promise.resolve(thenable).then((v) => log('adopted value: ' + v));
// 4. finally: runs on either outcome, gets no argument, passes the original result through.
Promise.reject(new Error('boom'))
.finally((...args) => log('finally ran with ' + args.length + ' arguments'))
.catch((e) => log('catch still sees ' + e.message));
// 5. Wrapping a callback API with new Promise
function legacyRead(cb) { setTimeout(() => cb(null, 'file contents'), 1); }
const readP = () => new Promise((resolve, reject) => legacyRead((err, data) => (err ? reject(err) : resolve(data))));
readP().then((v) => log('wrapped callback API gave ' + v));
// 6. States are one-way: a second resolve is ignored
const once = new Promise((res, rej) => { res('first'); res('second'); rej(new Error('ignored')); });
once.then((v) => log('settled once with ' + v));
setTimeout(() => {
console.log(out.join('\n'));
console.log('handlers never ran inline:', out.indexOf('sync: after then()') < out.indexOf('handler on already-settled promise got value'));
}, 30);
Run in a Node 22 container, it prints:
sync: before then()
sync: after then()
sync: resolving now, handler has not run
handler on already-settled promise got value
handler attached while pending got later
then() of the thenable called
finally ran with 0 arguments
settled once with first
adopted value: from thenable
catch still sees boom
wrapped callback API gave file contents
handlers never ran inline: true
Reading the output. Handlers go into the microtask queue in the order the promises were ready for them, and a job that queues more work puts it at the back. After the synchronous code finishes, the queue holds five jobs, in source order: the handler on the already-settled promise (case 1), the handler on the promise resolved by resolveLater (case 2), the job that will call the thenable's then (case 3), the finally handler (case 4) and the settled once handler (case 6). They run in that order, which gives lines 4 to 8, except that the thenable's handler is not in the queue yet:
-
The thenable job calls
then()of the thenable (line 6). That call resolves the promise, and only then is theadopted valuehandler queued, at the back of the queue, behind thefinallyandsettled oncejobs. That is whyadopted value(line 9) prints aftersettled once(line 8), andthen() of the thenable called(line 6) prints beforefinally ran(line 7): the thenable job was queued before thefinallyhandler, but its handler needs a second queue pass. -
The
catch still sees boomline (line 10) comes later still, becausefinallyitself returns an internal promise that must settle before the rejection continues down the chain. -
Attached after settling:
Promise.resolve('value').then(...)registers a handler on an already-fulfilled promise. Thesync:lines show the handler did not run inline; it ran after the script's synchronous code finished. -
Attached before settling: the handler waits while the promise is pending; calling
resolveLatermoves it into the microtask queue, but still not inline. -
Why never synchronous: if a handler could sometimes run immediately (already settled) and sometimes later (pending), the same code would have two different orderings depending on timing. Always deferring gives one predictable order, and callers can rely on finishing their own setup first. The same reason applies to the thenable line:
Promise.resolve(thenable)does not callthenable.thenimmediately, it queues that call as a microtask, so the adopted value arrives one queue pass later than an ordinary resolved value: first a job calls the thenable'sthen, then the handler is queued. -
Thenable: the object with a
then(onFulfilled)method was adopted: itsthenwas called with resolver functions, and its value became the promise's result. -
finally: runs on success or failure, receives no argument (0 arguments in the output), and passes the original outcome through, so the followingcatchstill saw the originalboomerror. -
Settle once:
res('first'); res('second'); rej(...)kept only the first.
Wrapping a callback API with new Promise
Callback APIs use cb(err, data). Wrap them once so the rest of the code can use promises or await:
const readP = () => new Promise((resolve, reject) =>
legacyRead((err, data) => (err ? reject(err) : resolve(data))));
The executor is the function passed to new Promise((resolve, reject) => ...). It runs synchronously and immediately; only the handlers are deferred. For Node's standard error-first functions, util.promisify does this wrapping for you.
Callbacks versus promises
| Callback | Promise | |
|---|---|---|
| Who controls when your code runs | The API that receives your function; it could call it twice, never, or synchronously | The promise: settles once, and handlers run once, asynchronously |
| Errors | Passed as the first argument, you must check each time | A rejection travels down the chain to the next .catch |
| Composition | Nested functions | Chains, Promise.all, and await |
Pitfalls
- Forgetting to
returna promise inside a.then: the next step does not wait for it. - Throwing inside a
new Promiseexecutor works (it rejects the promise), but throwing inside a plain callback inside it, after the executor returned, does not. The first promise below is rejected and caught; the second stays pending forever and the error escapes as a process-leveluncaughtException.
process.on('uncaughtException', (e) => console.log('uncaughtException:', e.message));
// Throwing directly in the executor rejects the promise.
new Promise(() => { throw new Error('sync throw'); })
.catch((e) => console.log('caught:', e.message));
// Throwing inside a timer callback started by the executor does not: the executor
// has already returned, so the error is not turned into a rejection.
const p = new Promise(() => { setTimeout(() => { throw new Error('late throw'); }, 0); });
p.catch(() => console.log('never printed'));
Run in a node:22 container as late_throw.js (Node 22), it prints:
caught: sync throw
uncaughtException: late throw
- Treating
finallyas a place to transform the result: its return value is ignored unless it throws or returns a rejected promise.
Running the code
docker run --rm --ulimit core=0 -v "$PWD":/w -w /w node:22 node promise_states.js
docker run --rm --ulimit core=0 -v "$PWD":/w -w /w node:22 node late_throw.js
Save the large block as promise_states.js and the pitfall block as late_throw.js. Both are CommonJS files and need no install or config.
A dashboard has 50 independent widgets, and each one needs its own API call. How would you load them? Weigh the options on total latency, CPU and memory cost, network use, what the user sees while data arrives, and what happens when some calls fail, then tell me which you would pick and when.
Sample Answer
Direct answer
I would load the widgets in a bounded parallel pool (about 6 at a time), ordered so the widgets above the fold (the ones visible without scrolling) go first, with each widget painting its own skeleton (a grey placeholder shaped like the widget), data or error state as soon as its own call settles. Fully sequential loading is the slowest possible option because the delays add up. Firing all 50 at once is fastest only when nothing in the path pushes back (a rate limit is a cap the server puts on how many requests one client may send in a period of time, and it is one common source of pushback). In the idealised arithmetic below the pool is slower than firing everything (1,800 ms against 200 ms), but it is nearly as fast wherever the browser caps connections per host anyway, because the extra requests would queue regardless of what the code does. The pool also never queues unboundedly in your own code and keeps one failing widget from affecting any other. The thing that most changes what the user sees is not the fetch strategy but the render policy: paint on arrival, not after everything.
The four options, with the arithmetic
Assume 50 calls that each take 200 ms on their own and do not slow each other down. These figures are derived, not measured.
| Option | Total latency | CPU and memory | Network | What the user sees | When some calls fail |
|---|---|---|---|---|---|
| Sequential (waterfall: each call waits for the previous one) | 50 x 200 ms = 10,000 ms | Lowest: one response alive at a time | One request in flight | Widgets fill in one at a time over ten seconds; the last widget is the slowest to appear | Easy to handle each, but a thrown error that is not caught stops everything after it |
Full parallel (Promise.all over all 50) | About 200 ms (the slowest single call) | 50 responses parsed and 50 renders close together; a burst of work on the main thread | 50 requests at once: browsers cap parallel connections per host (HTTP/1.x commonly 6 per MDN; HTTP/2 multiplexes many on one connection, meaning it sends many requests at the same time over a single connection), so extra requests queue anyway; a burst can hit server rate limits | All widgets appear nearly together if you wait for everything, otherwise as each arrives | Promise.all rejects on the first failure and discards the rest unless every call catches its own error |
| Bounded pool of 6 | ceil(50 / 6) = 9 rounds x 200 ms = 1,800 ms | At most 6 responses processed at once, steadier main-thread load | At most 6 in flight, matches the usual HTTP/1.x connection cap and spares the server | Widgets stream in; the first batch appears at about 200 ms | Per-widget catch, same as the others |
| Incremental by priority (pool plus ordering plus paint on arrival) | Same 1,800 ms for the last widget, but the visible ones are done in the first round (about 200 ms) | Same as the pool | Same as the pool, and offscreen widgets can wait until they scroll into view | Above-the-fold widgets are ready first; the rest fill in behind | Per-widget catch, same as the others |
Where the table says "9 rounds", that is the ceiling of 50 divided by 6, because the last round has only two calls. The "slowest single call" claim for full parallel assumes the server and network do not slow down under 50 simultaneous requests, which is often untrue.
Code that runs
The table uses 200 ms per call because the arithmetic is easy to follow. The program uses shorter delays (20 to 49 ms, 34.3 ms on average, since the 50 delays add up to 1,715 ms) so it finishes quickly, which is why its times (1715, 300, 49 ms) differ from the table's (10,000, 1,800, 200 ms). The method is the same: with 6 slots, 1,715 / 6 = 285.8 ms is the lower bound, and the program's 300 ms is a little higher because the slots do not all finish at the same instant. With every call at 200 ms the same model gives 9 rounds x 200 = 1,800 ms.
Run in a node:22 container (Node 22.23), the program below simulates 50 widget calls (fixed per-widget delays of 20 to 49 ms, widgets 13 and 41 always fail) and prints the peak number of simultaneous calls, how many widgets ended ready and error, and a finish time computed by a scheduling model from the same delays. Its assert calls fail the run if a strategy exceeds its limit, loses a result, lets a failure leak into another widget, or fails to start the visible widgets first.
import assert from 'node:assert/strict';
const sleep = (ms, v) => new Promise(r => setTimeout(r, ms, v));
const WIDGETS = 50;
const FAILING = new Set([13, 41]); // these two calls always fail
const latency = i => 20 + ((i * 7) % 30); // 20..49 ms, fixed per widget
const VISIBLE = [44, 45, 46, 47, 48, 49]; // widgets above the fold, shown first (not the first six by index)
// A fake API that records how many calls are in flight at once and the order they start.
function makeApi() {
const s = { active: 0, peak: 0, started: [] };
s.fetchWidget = async i => {
s.started.push(i); s.active++; s.peak = Math.max(s.peak, s.active);
try {
await sleep(latency(i));
if (FAILING.has(i)) throw new Error(`widget ${i} failed`);
return { id: i, value: i * 100 };
} finally { s.active--; }
};
return s;
}
// The UI: each widget is 'loading' until its own call settles. Records every state change.
function makeUi() {
const state = Array.from({ length: WIDGETS }, () => 'loading');
const log = [];
return { state, log,
show: (i, st) => { state[i] = st; log.push(`${i}:${st}`); },
count: st => state.filter(x => x === st).length };
}
// Bounded parallelism with a shared queue of indexes (workers pull the next one).
async function pool(indexes, limit, job) {
const queue = [...indexes];
const worker = async () => { while (queue.length) await job(queue.shift()); };
await Promise.all(Array.from({ length: Math.min(limit, queue.length) }, worker));
}
// Each widget handles its own failure, so one bad call never discards the others.
const loadOne = (api, ui) => async i => {
try { await api.fetchWidget(i); ui.show(i, 'ready'); }
catch { ui.show(i, 'error'); } // an error card with a Retry button in a real UI
};
const strategies = {
sequential: (api, ui) => pool([...Array(WIDGETS).keys()], 1, loadOne(api, ui)),
parallelAll: (api, ui) => Promise.all([...Array(WIDGETS).keys()].map(loadOne(api, ui))),
pool6: (api, ui) => pool([...Array(WIDGETS).keys()], 6, loadOne(api, ui)),
// Visible widgets first, then the rest, still at most 6 at a time, painting each on arrival.
prioritisedPool6: (api, ui) => {
const rest = [...Array(WIDGETS).keys()].filter(i => !VISIBLE.includes(i));
return pool([...VISIBLE, ...rest], 6, loadOne(api, ui));
},
};
// Modelled finish time (greedy list scheduling), computed from the same latency() function.
function modelledFinish(limit, order = [...Array(WIDGETS).keys()]) {
const free = Array(Math.min(limit, WIDGETS)).fill(0);
for (const i of order) {
const k = free.indexOf(Math.min(...free));
free[k] += latency(i);
}
return Math.max(...free);
}
const expectedPeak = { sequential: 1, parallelAll: 50, pool6: 6, prioritisedPool6: 6 };
for (const [name, run] of Object.entries(strategies)) {
const api = makeApi(), ui = makeUi();
await run(api, ui);
assert.equal(ui.count('ready'), 48); assert.equal(ui.count('error'), 2); // failures isolated
assert.equal(api.peak, expectedPeak[name]);
// Priority is observable only if the visible widgets are NOT the first six by index.
if (name === 'prioritisedPool6') assert.deepEqual(api.started.slice(0, 6), VISIBLE);
if (name === 'pool6') assert.notDeepEqual(api.started.slice(0, 6), VISIBLE);
const limit = { sequential: 1, parallelAll: 50, pool6: 6, prioritisedPool6: 6 }[name];
const order = name === 'prioritisedPool6'
? [...VISIBLE, ...[...Array(WIDGETS).keys()].filter(i => !VISIBLE.includes(i))] : undefined;
console.log(`${name.padEnd(17)} peak in flight=${String(api.peak).padStart(2)} ready=${ui.count('ready')} error=${ui.count('error')} modelled finish=${modelledFinish(limit, order)} ms`);
}
// A "wait for everything, then render once" policy waits for the slowest call, whatever the fetch strategy.
{
const api = makeApi(), ui = makeUi();
const results = await Promise.allSettled([...Array(WIDGETS).keys()].map(i => api.fetchWidget(i)));
const rendered = results.filter(r => r.status === 'fulfilled').length;
assert.equal(rendered, 48);
console.log('allSettled then render once: 48 widgets painted in one step, after the slowest call (' + Math.max(...[...Array(WIDGETS).keys()].map(latency)) + ' ms modelled)');
}
sequential peak in flight= 1 ready=48 error=2 modelled finish=1715 ms
parallelAll peak in flight=50 ready=48 error=2 modelled finish=49 ms
pool6 peak in flight= 6 ready=48 error=2 modelled finish=300 ms
prioritisedPool6 peak in flight= 6 ready=48 error=2 modelled finish=302 ms
allSettled then render once: 48 widgets painted in one step, after the slowest call (49 ms modelled)
How modelledFinish works (greedy list scheduling: hand each job, in list order, to whichever slot frees up first). free has one entry per slot and holds the time that slot becomes free, all starting at 0. For each widget i, Math.min(...free) finds the earliest free time, free.indexOf(...) finds which slot that is, and free[k] += latency(i) books the widget onto that slot. When every widget is booked, Math.max(...free) is the time the last slot finishes. With one slot the sum of all delays is 1715 ms; with 50 slots every widget starts at 0 so the answer is the longest delay, 49 ms; with 6 slots it lands at 300 ms.
Reading it:
- Peak in flight is exactly 1, 50, 6 and 6, which is the property that distinguishes the strategies on resource use.
- All four end with 48 ready and 2 error, because each widget catches its own failure (
loadOne). That is the shape to copy: the error card with a Retry button belongs to the widget, so a failed widget never discards the others. The last block shows the contrasting policy: waiting forPromise.allSettledand rendering once makes the user wait for the slowest call before seeing anything, even though 48 of 50 calls succeeded. - The modelled finish times (1715 ms sequential, 49 ms for all in parallel, 300 ms for 6 at a time) come from the greedy scheduling model over the simulated delays. They assume the delays do not change with concurrency. With a real server the parallel number rises and the gap to the pool shrinks.
prioritisedPool6starts the six visible widgets first and keeps the rest behind them at the same limit. The visible widgets are deliberately numbers 44 to 49, not the first six by index, so theassertonapi.startedpasses only when the ordering step exists: the plainpool6run starts widgets 0 to 5 first, and a secondassertconfirms it does not match. Its modelled finish is 302 ms rather than 300 ms because changing the order changes how the delays pack into the six slots.
Recommendation, and what would change it
Pick the incremental pool: limit about 6, visible widgets first, skeleton per widget, error and retry per widget. It bounds load on both the client and server, shows useful content early, and degrades gracefully. For widgets far below the fold, load them when they approach the viewport (an IntersectionObserver, the browser API that reports when an element enters the viewport, the visible part of the page) so the user never pays for what they do not scroll to.
What would flip the choice:
- All 50 calls go over HTTP/2 to a service you own and have load tested: raise the limit, or use full parallel, because the browser's per-host connection cap no longer applies in the same way.
- Widgets depend on one another (widget B needs an id from widget A): that pair has to be sequential, the rest stay parallel.
- The 50 calls hit the same backend and mostly repeat the same lookups: a single combined endpoint (one round trip returning all widget data, or at least grouped calls) beats any client-side scheduling.
- A call that fails repeatedly: stop retrying and show the error state rather than hammering the service. Add a retry with a delay between attempts, capped in count.
Pitfalls
- Using
Promise.alland letting one rejection hide 49 successful widgets. - Choosing the pool size without a reason. 6 is not magic; it follows the common HTTP/1.x per-host connection cap, and the right number depends on the protocol and the server.
- Rendering all widgets in one update after the slowest call, which turns a good fetch strategy into a bad user experience.
- Letting a late response for a widget that has been unmounted or refreshed write into the UI. Abort or ignore stale responses per widget.
A gallery page loads many images and thumbnails. When does it make sense to wait for every request, and when for only the first to answer? Give a concrete frontend case for each, and say what each approach does when one of the requests fails and what the user ends up seeing.
Sample Answer
Direct answer
Start here: ask what the screen needs. If it needs every result, wait for all of them; if one good answer is enough, take the first; if one failure must not spoil the rest, collect each outcome separately.
Wait for every request when the screen is only correct with all the results, and wait for the first when any one answer is enough. Concretely, a gallery's thumbnail grid needs every thumbnail, but a failure of one should not blank the rest, so use Promise.allSettled (wait for all, never short-circuit). A hero image that exists on three CDN mirrors (identical copies of the file on different content-delivery-network servers, so any one of them will do) needs only the first mirror that works, so use Promise.any (first success). The plain versions, Promise.all and Promise.race, both short-circuit on the first rejection (they settle immediately with that failure instead of waiting for the remaining inputs), so on a failing request they hand the user an error instead of a partial page. Use them only when that is the behaviour you want.
The four combinators
A promise combinator is a function that takes several promises and returns one promise built from their outcomes.
| Combinator | Resolves with | Rejects when | Failure leaves the other requests |
|---|---|---|---|
Promise.all | Array of all values, in input order | The first input rejects | Still running (nothing cancels them) |
Promise.allSettled | Array of {status, value} or {status, reason}, one per input | Never; it always waits for every input | Waited for |
Promise.race | The first input to settle, success or failure | The first settled input is a rejection | Still running |
Promise.any | The first input to fulfill | Every input rejected: an AggregateError whose errors array holds the reasons in input order | Still running after the first success |
(MDN documents the any semantics, the AggregateError, and that a result does not cancel the other operations.)
Each one on a gallery, with what the user sees
AggregateError is a built-in error type that bundles several errors into one: its errors array holds each underlying failure. The script uses fake loaders with fixed delays (20, 40, 60 ms etc.) so the outcomes are reproducible, and it prints each result. Reading the helpers: load(url, ms, fail, signal) returns a promise that settles after ms milliseconds, resolving with the url or rejecting, and it also rejects early if the optional signal (an AbortSignal, the cancellation token an AbortController hands out) fires. show(label, p) waits for p, then prints the label padded to 22 characters followed by either the JSON of the result or the error's name and message; for an AggregateError it joins the messages from e.errors. Each await show(...) line below therefore matches one line of the output.
// Fake image loader: resolves with the url after `ms`, or rejects if `fail` is true.
const load = (url, ms, fail = false, signal) => new Promise((resolve, reject) => {
const t = setTimeout(() => (fail ? reject(new Error(url + ' failed')) : resolve(url)), ms);
signal?.addEventListener('abort', () => { clearTimeout(t); reject(signal.reason); });
});
const show = async (label, p) => console.log(label.padEnd(22), await p.then((v) => JSON.stringify(v), (e) => e.name + ': ' + (e.errors ? e.errors.map((x) => x.message).join(', ') : e.message)));
const thumbs = () => [load('t1.jpg', 60), load('t2.jpg', 20, true), load('t3.jpg', 40)];
await show('all (one fails)', Promise.all(thumbs()));
await show('allSettled', Promise.allSettled(thumbs()).then((rs) => rs.map((r) => r.status === 'fulfilled' ? r.value : 'rejected: ' + r.reason.message)));
await show('any (mirrors)', Promise.any([load('cdn-a/hero.jpg', 50, true), load('cdn-b/hero.jpg', 80), load('cdn-c/hero.jpg', 30, true)]));
await show('any (all fail)', Promise.any([load('a', 10, true), load('b', 20, true)]));
await show('race (fast failure)', Promise.race([load('slow-ok', 60), load('fast-fail', 10, true)]));
// race as a timeout: the losing request keeps running unless it is aborted
let finished = false;
const slow = new Promise((r) => setTimeout(() => { finished = true; r('slow body'); }, 80));
const timeout = (ms) => new Promise((_, rej) => setTimeout(() => rej(new Error('timed out')), ms));
await show('race timeout', Promise.race([slow, timeout(20)]));
console.log('slow request still ran to completion:', await new Promise((r) => setTimeout(() => r(finished), 120)));
let abortedRan = false;
const ac = new AbortController();
const abortable = new Promise((resolve, reject) => {
const t = setTimeout(() => { abortedRan = true; resolve('slow body'); }, 80);
ac.signal.addEventListener('abort', () => { clearTimeout(t); reject(ac.signal.reason); });
});
const timer = setTimeout(() => ac.abort(new Error('aborted by caller')), 20);
await show('race with abort', abortable.finally(() => clearTimeout(timer)));
console.log('aborted request ran to completion:', await new Promise((r) => setTimeout(() => r(abortedRan), 120)));
console.log('empty input: all ->', JSON.stringify(await Promise.all([])), '| any ->', await Promise.any([]).catch((e) => e.name));
Run in a Node 22 container, it prints:
all (one fails) Error: t2.jpg failed
allSettled ["t1.jpg","rejected: t2.jpg failed","t3.jpg"]
any (mirrors) "cdn-b/hero.jpg"
any (all fail) AggregateError: a failed, b failed
race (fast failure) Error: fast-fail failed
race timeout Error: timed out
slow request still ran to completion: true
race with abort Error: aborted by caller
aborted request ran to completion: false
empty input: all -> [] | any -> AggregateError
allwith one failing thumbnail rejects witht2.jpg failedand the other two successful results are thrown away. The user sees an error state for the whole gallery. Right when the page cannot render without every piece (for example a checkout page needing both cart and prices).allSettledreturns all three outcomes. The gallery renders two thumbnails and one placeholder with a retry button. This is the right default for independent tiles.anyover mirrors resolved withcdn-b/hero.jpg, the first to succeed, ignoring the two failures. If every mirror fails you get anAggregateError(shown on theany (all fail)line) and should render the fallback image. Right for redundant sources of the same thing.racewith a fast failure rejects with the failure even though a slower request would have succeeded. Right for "first answer of any kind", rarely for images.raceas a timeout is the common use: race the request against a timer that rejects. The output shows the pitfall:slow request still ran to completion: true. The loser keeps running, using bandwidth and possibly updating state later.- Fix: give the request an
AbortController(a browser and Node API for cancelling work); on timeout callabort()and the request is torn down. Therace with abortlines show the aborted request never completed (false). Withfetchyou pass{ signal }in the options; the request then rejects once aborted. - Empty input behaves differently:
Promise.all([])resolves with[], whilePromise.any([])rejects with anAggregateError. A gallery with zero images must not be treated as a failure.
Recommendation
Thumbnails: allSettled, render fulfilled ones, placeholders for rejected ones, one retry per tile. Hero image: any over the mirrors with an AbortController per request so the losers are cancelled once one wins. Page data that the screen needs in full: all, with a single error state. Timeouts: race against a timer, but always with abort() on the loser.
Pitfalls
- Using
allfor independent tiles and then wondering why one 404 blanks the page. - Reading the
allSettledarray as values: each entry is an object; checkstatusfirst. - Assuming
raceoranycancel the losers. They do not. - Unbounded fan-out (starting many requests at the same moment): firing hundreds of requests at once wastes bandwidth and competes with more important requests; cap concurrency when the count is large.
Predict the exact console output of this snippet and explain why it is in that order.
console.log('start');
setTimeout(() => console.log('timeout 0'), 0);
Promise.resolve().then(() => console.log('promise1')).then(() => console.log('promise2'));
console.log('end');
Sample Answer
Direct answer
Start here: after the script's own lines, the engine runs every queued promise callback, and only then takes the next timer. In order: plain code, then promises, then timers.
The output is start, end, promise1, promise2, timeout 0. Synchronous code runs first. When the script finishes, the engine drains the microtask queue (the queue for promise callbacks, which always empties before the next timer task is picked). setTimeout only schedules a task (a unit of work for a later turn of the event loop), and a delay of 0 does not mean "now": it means "no sooner than a later turn".
Step-by-step trace
| Step | What happens | Queue state afterwards |
|---|---|---|
| 1 | console.log('start') runs synchronously | none |
| 2 | setTimeout(...) registers a timer; its callback is queued as a task when the timer expires | task queue: timeout callback |
| 3 | Promise.resolve() is already fulfilled, so .then(...) queues its callback immediately. The second .then is attached to the promise returned by the first, which is still pending | microtasks: promise1 callback |
| 4 | console.log('end') runs synchronously. The script is finished and the stack is empty | |
| 5 | Microtask checkpoint (the point where the engine empties the microtask queue): promise1 logs, then its returned promise fulfills, which queues the promise2 callback; the checkpoint keeps going until the queue is empty, so promise2 logs | microtasks empty |
| 6 | The event loop picks the timeout task and timeout 0 logs |
The key rule: microtasks queued by microtasks still run before the next task (MDN microtask guide). That is why promise2, which only exists after promise1 finishes, still beats the timer.
Run unchanged in a Node 22 container, the snippet prints:
start
end
promise1
promise2
timeout 0
A browser follows the same ordering for this snippet, because it rests on the microtask rule above and not on any Node-specific phase.
Adding async/await
An async function runs synchronously until its first await; the rest of the function becomes a promise reaction, so it runs as a microtask.
const out = [];
const log = (m) => out.push(m);
async function f() {
log('f: before await');
await null;
log('f: after await');
}
log('script start');
setTimeout(() => log('timeout 0'), 0);
f();
Promise.resolve().then(() => log('then'));
log('script end');
setTimeout(() => console.log(out.join('\n')), 20);
Run in the same container, it prints:
script start
f: before await
script end
f: after await
then
timeout 0
f: after await comes before then because the await null continuation was queued first (during f()), and microtasks run in the order they were queued. Both come before timeout 0.
When DOM updates become visible after async work
In a browser event handler, DOM changes are not painted until the rendering update, which comes after the microtask queue is empty. Consequences:
- Several DOM changes made across
.thencallbacks orawaitpoints in the same chain are painted together; the user only sees the last state. Code that sets "Loading..." and then, after an already-resolved promise, sets "Done" never shows "Loading...". - A change made in a timer callback sits in a different task, so the browser may paint between the two. Whether it does is up to the browser, not something your code controls.
- To force a paint between two states, yield to a task boundary (
await new Promise(r => setTimeout(r))orawait scheduler.yield()where supported, a method MDN lists as limited availability).
Starvation: detection and mitigation
Because the queue must be empty before the loop moves on, microtasks that keep queuing more microtasks starve (leave without a turn) timers, input events and rendering. The chain below queues 1,000,000 steps; the timer registered before it cannot run until all of them finish. The second block does the same amount of work in slices of 1,000 and lets the timer in after the first slice.
// A chain of microtasks that keeps queuing the next one holds back every timer until it ends.
const STEPS = 1_000_000;
let steps = 0, stepsWhenTimerRan = null;
setTimeout(() => { stepsWhenTimerRan = steps; }, 0);
(function microChain() {
if (++steps < STEPS) queueMicrotask(microChain);
else setTimeout(() => console.log('microtask chain: timer ran only after all steps:', stepsWhenTimerRan === STEPS), 0);
})();
// The same amount of work split into timer-sized chunks lets the other timer in early.
let done = 0, stepsWhenOtherTimerRan = null;
setTimeout(() => { stepsWhenOtherTimerRan = done; }, 0);
(function chunk() {
for (let i = 0; i < 1000 && done < STEPS; i++) done++;
if (done < STEPS) setTimeout(chunk, 0);
else console.log('chunked work: other timer ran before the work finished:', stepsWhenOtherTimerRan < STEPS);
})();
Run in the same container, it prints:
microtask chain: timer ran only after all steps: true
chunked work: other timer ran before the work finished: true
Reading the demo: steps counts how many links of the microtask chain have run, and stepsWhenTimerRan records what steps was at the instant the first timer callback fired. The check stepsWhenTimerRan === STEPS is true only if the timer fired after the last link, which means the timer waited for all 1,000,000 microtasks. In the second block, done counts finished units of work and stepsWhenOtherTimerRan records done when the second timer fired. Each chunk call does up to 1,000 units and then re-schedules itself with setTimeout, so the timer gets a turn after the first slice, when done is 1,000 (far below STEPS). That is why the check stepsWhenOtherTimerRan < STEPS prints true.
- Detection: the page freezes (clicks do nothing) but the CPU is busy; the DevTools Performance panel shows one very long task with no gaps, and the Long Tasks API (
PerformanceObserverwithlongtaskentries) flags tasks over 50 ms.PerformanceObserveris the browser object that delivers performance entries to your callback as they are recorded, and the Long Tasks API is the entry type (longtask) for main-thread tasks that run longer than 50 ms. A task that never ends points to a self-requeuing microtask. - Mitigation: put a task boundary in loops (
setTimeout,scheduler.yield(), or message-channel scheduling, which means posting a message through aMessageChannelso the receiving handler runs as a new task), cap the number of recursive steps, move heavy computation to a Web Worker, and replace "retry immediately" promise loops with delayed retries.
Pitfalls
- Believing
setTimeout(fn, 0)beats promise callbacks. It never does when both are pending. - Counting
thenhops wrongly: each.thenhandler is its own microtask, so a chain of three.thens yields to other already-queued microtasks between hops. - Treating the exact order of
setTimeoutagainstsetImmediate(a Node.js function that runs a callback in the loop phase right after I/O callbacks) or against rendering as fixed. Only the microtask-before-task rule is fixed here.
Write retryFetch(url, options, retries, backoffBaseMs) that retries only failures that are worth retrying, spaces attempts so many clients do not retry in lockstep, and stops a single hung attempt from blocking the rest. Explain which responses you would not retry and why.
Sample Answer
Direct answer
Retry only when repeating the identical request has a real chance of succeeding and cannot do harm: network failures, a timed-out attempt, and the transient statuses 408, 429, 500, 502, 503 and 504, and only for requests that are safe to repeat. Wait between attempts with exponential backoff (a delay ceiling that doubles each time) and full jitter (the actual delay is a random number between 0 and that ceiling), give every attempt its own deadline so one hung connection cannot eat the whole budget, and make every wait cancellable. Three decisions carry the design: which outcomes are worth retrying (the table below), how long to wait (backoff, jitter and the server's Retry-After header, which tells the client how many seconds to wait), and when to stop (the retry count, the per-attempt deadline and the caller's abort).
What to retry and what not to
| Outcome | Retry? | Why |
|---|---|---|
fetch rejects with a TypeError (DNS failure, connection reset, offline) | Yes | Nothing reached a handler, the next attempt may find a working path |
The attempt's own deadline fires (TimeoutError) | Yes | A hung connection says nothing about the next one |
| 408 Request Timeout | Yes | MDN (Mozilla's web documentation): the server closed the connection and the client may repeat the request on a new one |
| 429 Too Many Requests | Yes, after the wait the server asks for | Rate limiting is temporary by definition; Retry-After says how long |
| 500, 502, 503, 504 | Yes, with a small retry count | 502 and 504 come from a gateway that got a bad answer or none from the server behind it; 503 means overloaded or in maintenance; 500 may be a one-off fault or a deterministic bug, so keep the count low |
| 400, 401, 403, 404, 405, 409, 410, 422 | No | The server understood and refused: the same request gets the same answer. For 401, refresh the token and send a new request instead of replaying the old one |
| 501 Not Implemented | No | The server will never support it |
Any response when the call is a POST or PATCH without an idempotency key | No | Those methods are not guaranteed idempotent (repeating them may repeat the effect, such as a second charge), so a retry after an ambiguous failure can double-apply. With an Idempotency-Key header the server can de-duplicate, and the code below then allows retries |
| The caller aborted | No | The caller's decision outranks the policy |
Retry-After (seconds or an HTTP date, per MDN) is honoured as a minimum wait. A value larger than a sane limit is not obeyed: the 429 is returned to the caller instead of sleeping for an hour.
Why jitter, in numbers
Suppose 1000 clients all got a 503 at the same instant (a deploy restarted the backend). With plain exponential backoff, all 1000 retry together at the same moment, the backend gets the same spike again, fails again, and the pattern repeats: a thundering herd (many clients acting in lockstep). With full jitter each client picks its own random delay below the ceiling, so the retries spread out. The harness below replays that with a seeded generator, so the numbers are repeatable: for the third retry (ceiling 200 ms x 2^2 = 800 ms) it prints the size of the busiest 100 ms window.
Code and test harness
retryFetch takes the four requested parameters and a fifth options object that exists so tests can inject the random source and the sleep function. retries counts retries after the first attempt, so retries = 3 means up to 4 attempts. Each attempt gets AbortSignal.timeout(attemptTimeoutMs) merged with the caller's signal through AbortSignal.any (which combines several signals into one that aborts when any of them does), and the backoff wait is an abortable sleep that rejects the moment the caller's signal fires. In the options object, capMs is the highest value the exponential ceiling may reach, maxWaitMs is the longest Retry-After the function will obey, and random and sleep can be replaced in tests. An idempotency key is a unique token sent in the Idempotency-Key header so the server can recognise a repeat of the same operation and apply it once.
import http from 'node:http';
import assert from 'node:assert/strict';
const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']);
function abortableSleep(ms, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(signal.reason);
const timer = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, ms);
function onAbort() { clearTimeout(timer); reject(signal.reason); }
signal?.addEventListener('abort', onAbort, { once: true });
});
}
function retryAfterMs(res) {
const h = res.headers.get('retry-after');
if (!h) return 0;
if (/^\d+$/.test(h)) return Number(h) * 1000;
const t = Date.parse(h);
return Number.isNaN(t) ? 0 : Math.max(0, t - Date.now());
}
export async function retryFetch(
url, options = {}, retries = 3, backoffBaseMs = 200,
{ attemptTimeoutMs = 5000, capMs = 10000, maxWaitMs = 30000, random = Math.random, sleep = abortableSleep } = {},
) {
const method = (options.method ?? 'GET').toUpperCase();
const replayable = SAFE_METHODS.has(method) || new Headers(options.headers).has('Idempotency-Key');
const maxRetries = replayable ? retries : 0;
for (let attempt = 0; ; attempt++) {
// One signal per attempt: the caller's cancel OR this attempt's own deadline.
const signals = [AbortSignal.timeout(attemptTimeoutMs)];
if (options.signal) signals.push(options.signal);
let res;
try {
res = await fetch(url, { ...options, signal: AbortSignal.any(signals) });
} catch (err) {
if (options.signal?.aborted) throw err; // caller cancelled: stop
if (attempt >= maxRetries) throw err; // out of retries
// otherwise: network failure (TypeError) or this attempt's TimeoutError: retry
await sleep(Math.floor(random() * Math.min(capMs, backoffBaseMs * 2 ** attempt)), options.signal);
continue;
}
if (!RETRYABLE_STATUS.has(res.status) || attempt >= maxRetries) return res;
const ceiling = Math.min(capMs, backoffBaseMs * 2 ** attempt);
const serverWait = retryAfterMs(res);
if (serverWait > maxWaitMs) return res; // "come back in an hour": surface the 429 instead of hanging
const delay = Math.max(Math.floor(random() * ceiling), serverWait); // full jitter, but never earlier than Retry-After
await res.body?.cancel(); // release the connection of the discarded response
await sleep(delay, options.signal);
}
}
// ---------------- harness ----------------
const hits = {};
const plans = {
'/flaky': [503, 503, 200],
'/missing': [404, 200],
'/bad': [400, 200],
'/limited': [429, 200],
'/banned': [429, 200],
'/hang1': ['hang', 200],
'/always': [503, 503, 503, 503, 503],
'/post': [503, 200],
'/hangall': ['hang'],
'/hang2': ['hang', 200],
'/cap': [503],
'/dated': [429, 200],
};
const HERD_PLAN = [503, 503, 503, 200]; // every /herd?c=N client fails three times, then succeeds
const server = http.createServer((req, res) => {
const n = (hits[req.url] = (hits[req.url] ?? 0) + 1);
const plan = req.url.startsWith('/herd') ? HERD_PLAN : (plans[req.url] ?? [200]);
const step = plan[Math.min(n - 1, plan.length - 1)];
if (step === 'hang') return; // never answers
if (step === 429) res.setHeader('Retry-After', req.url === '/banned' ? '3600' : req.url === '/dated' ? new Date(Date.now() + 5000).toUTCString() : '2');
res.statusCode = step; res.end(String(step));
}).listen(0);
const base = `http://127.0.0.1:${server.address().port}`;
const reset = () => { for (const k of Object.keys(hits)) delete hits[k]; };
const delays = [];
const fakeSleep = async (ms) => { delays.push(ms); };
const almostMax = () => 0.999;
// 1. 503, 503, 200 => success on 3rd attempt, delays below the exponential ceilings 200 and 400
let res = await retryFetch(`${base}/flaky`, {}, 3, 200, { random: almostMax, sleep: fakeSleep });
const delaysSeen = delays.splice(0);
console.log('flaky:', res.status, 'attempts =', hits['/flaky'], 'delays =', delaysSeen);
assert.equal(res.status, 200); assert.equal(hits['/flaky'], 3);
// full jitter: delay = floor(random * ceiling), so 0.999 gives 199 then 399 (a version without jitter would give 200 and 400)
assert.deepEqual(delaysSeen, [199, 399]);
// 2. 404 and 400 are returned at once, never retried
for (const p of ['/missing', '/bad']) {
res = await retryFetch(base + p, {}, 3, 200, { sleep: fakeSleep });
console.log(p, '->', res.status, 'attempts =', hits[p]);
assert.equal(hits[p], 1);
}
// 3. 429 with Retry-After: 2 waits at least 2000 ms even when jitter picks 0
res = await retryFetch(`${base}/limited`, {}, 3, 200, { random: () => 0, sleep: fakeSleep });
const limitedDelays = delays.splice(0);
console.log('limited:', res.status, 'delays =', limitedDelays);
assert.equal(res.status, 200); assert.deepEqual(limitedDelays, [2000]);
// 3c. Retry-After as an HTTP date (about 5 s ahead, whole seconds) is honoured as well
res = await retryFetch(`${base}/dated`, {}, 3, 200, { random: () => 0, sleep: fakeSleep });
const datedDelays = delays.splice(0);
console.log('dated:', res.status, 'waited between 3500 and 5000 ms:', datedDelays[0] >= 3500 && datedDelays[0] <= 5000);
assert.equal(res.status, 200); assert.equal(datedDelays.length, 1);
assert.ok(datedDelays[0] >= 3500 && datedDelays[0] <= 5000);
// 3b. a Retry-After far beyond the wait limit is not obeyed: the caller gets the 429 at once
res = await retryFetch(`${base}/banned`, {}, 3, 200, { sleep: fakeSleep });
console.log('banned:', res.status, 'attempts =', hits['/banned'], 'delays =', delays.splice(0));
assert.equal(res.status, 429); assert.equal(hits['/banned'], 1);
// 4. a hung first attempt is cut off by the per-attempt deadline, then retried
const watchdog = new Promise((_, reject) => setTimeout(() => reject(new Error('hung attempt was never cut off')), 3000).unref());
res = await Promise.race([retryFetch(`${base}/hang1`, {}, 2, 10, { attemptTimeoutMs: 150 }), watchdog]);
console.log('hang1:', res.status, 'attempts =', hits['/hang1']);
assert.equal(res.status, 200); assert.equal(hits['/hang1'], 2);
// 4b. after a timed-out attempt the retry also waits a jittered backoff before the next attempt
res = await retryFetch(`${base}/hang2`, {}, 2, 10, { attemptTimeoutMs: 150, random: almostMax, sleep: fakeSleep });
const hangDelays = delays.splice(0);
console.log('hang2:', res.status, 'attempts =', hits['/hang2'], 'delays =', hangDelays);
assert.equal(res.status, 200); assert.equal(hits['/hang2'], 2); assert.deepEqual(hangDelays, [9]);
// 5. out of retries on 5xx: the last response is returned (caller sees the 503)
res = await retryFetch(`${base}/always`, {}, 2, 5, { sleep: fakeSleep });
console.log('always:', res.status, 'attempts =', hits['/always']); delays.length = 0;
assert.equal(res.status, 503); assert.equal(hits['/always'], 3);
// 5b. capMs stops the ceiling from growing: ceilings 200, 400, then 500 (not 800, 1600, 3200)
res = await retryFetch(`${base}/cap`, {}, 5, 200, { random: almostMax, capMs: 500, sleep: fakeSleep });
const capDelays = delays.splice(0);
console.log('capped:', res.status, 'attempts =', hits['/cap'], 'delays =', capDelays);
assert.equal(res.status, 503); assert.equal(hits['/cap'], 6); assert.deepEqual(capDelays, [199, 399, 499, 499, 499]);
// 6. POST is not replayed on 503 unless it carries an Idempotency-Key
res = await retryFetch(`${base}/post`, { method: 'POST' }, 3, 5, { sleep: fakeSleep });
console.log('POST no key:', res.status, 'attempts =', hits['/post']);
assert.equal(hits['/post'], 1);
reset();
res = await retryFetch(`${base}/post`, { method: 'POST', headers: { 'Idempotency-Key': 'k1' } }, 3, 5, { sleep: fakeSleep });
console.log('POST with key:', res.status, 'attempts =', hits['/post']);
assert.equal(hits['/post'], 2);
// 7. abort during a backoff wait ends the call promptly with no further attempt
reset();
const ctl = new AbortController();
setTimeout(() => ctl.abort(), 100);
const t0 = performance.now();
await assert.rejects(
retryFetch(`${base}/always`, { signal: ctl.signal }, 5, 60000, { random: almostMax }),
(e) => e.name === 'AbortError',
);
const elapsed = performance.now() - t0;
console.log('abort in backoff: rejected, attempts =', hits['/always'], ', returned within 1 s:', elapsed < 1000);
assert.equal(hits['/always'], 1); assert.ok(elapsed < 1000);
// 7b. abort while an attempt is in flight: the call ends and no backoff wait is scheduled
reset(); delays.length = 0;
const ctl2 = new AbortController();
setTimeout(() => ctl2.abort(), 100);
await assert.rejects(
retryFetch(`${base}/hangall`, { signal: ctl2.signal }, 3, 5, { sleep: fakeSleep }),
(e) => e.name === 'AbortError',
);
console.log('abort during an attempt: rejected, backoff waits scheduled =', delays.length);
assert.equal(delays.length, 0);
// 8. jitter, measured through retryFetch itself: 1000 clients fail together; when does each make its third retry?
function mulberry32(a) { return () => { a |= 0; a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; }
const rnd = mulberry32(42);
const thirdRetry = [];
for (let c = 0; c < 1000; c++) {
const mine = [];
const r = await retryFetch(`${base}/herd?c=${c}`, {}, 3, 200, { random: rnd, sleep: async (ms) => { mine.push(ms); } });
assert.equal(r.status, 200);
thirdRetry.push(mine[2]); // ceiling for the third retry: 200 * 2^2 = 800 ms
}
const noJitter = thirdRetry.map(() => 800); // what plain exponential backoff would do
const busiest = (xs) => { const b = {}; for (const x of xs) b[Math.floor(x / 100)] = (b[Math.floor(x / 100)] ?? 0) + 1; return Math.max(...Object.values(b)); };
console.log('busiest 100 ms window, no jitter:', busiest(noJitter), '| full jitter:', busiest(thirdRetry));
assert.equal(busiest(noJitter), 1000); assert.ok(busiest(thirdRetry) < 200);
server.closeAllConnections(); server.close(); await new Promise((resolve) => process.stdout.write('', resolve)); // flush piped output before exiting
process.exit(0);
Run in a node:22 container (Node 22.23), it prints:
flaky: 200 attempts = 3 delays = [ 199, 399 ]
/missing -> 404 attempts = 1
/bad -> 400 attempts = 1
limited: 200 delays = [ 2000 ]
dated: 200 waited between 3500 and 5000 ms: true
banned: 429 attempts = 1 delays = []
hang1: 200 attempts = 2
hang2: 200 attempts = 2 delays = [ 9 ]
always: 503 attempts = 3
capped: 503 attempts = 6 delays = [ 199, 399, 499, 499, 499 ]
POST no key: 503 attempts = 1
POST with key: 200 attempts = 2
abort in backoff: rejected, attempts = 1 , returned within 1 s: true
abort during an attempt: rejected, backoff waits scheduled = 0
busiest 100 ms window, no jitter: 1000 | full jitter: 140
About mulberry32: it is a short seeded pseudo-random number generator, a function that returns the same "random" sequence every time it starts from the same seed (42 here). The odd constants are the algorithm's fixed bit-mixing steps and can be treated as a black box; it is used instead of Math.random only so the printed 140 is repeatable.
Reading it: the two delays 199 and 399 are the largest values the test's fixed random source allows under the ceilings 200 and 400; 404 and 400 are returned after one attempt; the 429 with Retry-After: 2 waits 2000 ms even though jitter picked 0; a Retry-After: 3600 is not obeyed; a Retry-After given as an HTTP date is honoured too; the hung first attempt is cut off by its own deadline and the second attempt succeeds, and a timed-out attempt is followed by a jittered wait (9 ms for a 10 ms ceiling) before the next one; retries = 2 against a permanently failing endpoint makes 3 attempts and then returns the last 503 response, so the caller can still read the status and body; the POST is replayed only with a key; aborting during the backoff wait ends the call within a second without a second attempt; aborting while an attempt is in flight ends the call with no backoff wait scheduled; and the herd drops from 1000 requests in the busiest window to 140. The ceiling is 800 ms, so the delays fall into 800 / 100 = 8 windows of 100 ms; 1000 clients spread evenly would put 1000 / 8 = 125 in each, and random spreading puts a little more than that in the busiest one (140 for this seed). Every line is guarded by an assert: the delays [199, 399] and [2000] are compared exactly, the abort test asserts the elapsed time, the hung-attempt test is raced against a 3 second watchdog so a missing deadline fails instead of hanging, and the herd figure is measured by calling retryFetch for 1000 simulated clients and collecting each one's third wait. Removing the jitter, the Retry-After floor, the per-attempt deadline, the abortable sleep, the caller-abort check, the backoff wait after a timed-out attempt, the capMs ceiling or the HTTP-date branch of the Retry-After parser from retryFetch makes the run exit with an assertion error.
Budget arithmetic
With the defaults (retries = 3, backoffBaseMs = 200, 5000 ms per attempt) the worst case is 4 attempts x 5000 ms plus the largest possible waits 200 + 400 + 800 ms:
4 x 5000 + (200 + 400 + 800) = 20000 + 1400 = 21400 ms
This figure holds for failures without a Retry-After header. A 429 or 503 that carries one can add up to maxWaitMs (30000 ms) to each wait, three times over with retries = 3, so a call that must stay inside a fixed budget needs the caller signal below rather than the arithmetic alone.
If the screen cannot wait that long, pass a caller signal such as AbortSignal.timeout(8000): it caps the whole call, because the per-attempt signal and the backoff sleep both listen to it.
Trade-offs and pitfalls
- Retries multiply load. One layer allowing 4 attempts can send 4 times the traffic to a struggling backend. If the browser, a gateway and a service each retry 4 times, one user action can become 4 x 4 x 4 = 64 requests at the bottom. Retry at one layer, and let the others fail fast.
- Cap the ceiling. Exponential growth without
capMsreaches minutes quickly. - Release discarded responses. A retried 503 still holds a connection until its body is read or cancelled, hence
res.body?.cancel(). - Do not retry what the user already abandoned. The
options.signalcheck inside thecatchis what stops a cancelled call from looking like a network failure and being retried. - Observe it. Log attempt number and reason; a rising retry rate is an early outage signal that a clean success rate hides.
Running the code
# save the listing as retry_fetch_test.mjs (Node 22 needs no install)
docker run --rm -v "$PWD":/w -w /w node:22 node retry_fetch_test.mjs
Unlock Full Question Bank
Get access to all 23 Asynchronous and Event-Driven Programming interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.