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.
Write a small JavaScript class that simulates a single-threaded event loop with a call stack, a microtask queue and a macrotask queue, with methods to schedule each kind of task and to run until idle. Then use it to predict the output order of a snippet that mixes setTimeout, Promise.then and queueMicrotask, and check the prediction against a real run.
Sample Answer
Direct answer
An event loop is a scheduler that runs one piece of JavaScript at a time to completion. Each piece is either the script itself or one callback taken from the macrotask queue (a "task": a timer, an I/O callback, a user event). When the call stack (the list of functions currently executing) becomes empty, the runtime drains the microtask queue (runs it until it is empty; it holds promise reactions and queueMicrotask callbacks) completely, including microtasks added while it drains, and only then takes the next macrotask. So for any snippet: all synchronous code first, then every microtask, then one timer, then every microtask again, and so on.
Start here: a tiny trace with real values. Take this four-line script.
console.log('A');
Promise.resolve().then(() => console.log('B'));
setTimeout(() => console.log('C'), 0);
console.log('D');
The script itself runs first, top to bottom. A prints, the .then callback is put in the microtask queue (queue: [B]), the timer is put in the macrotask queue (queue: [C]), and D prints. The script ends, the stack is empty, so the microtask queue is emptied: B prints. Only then is the next macrotask taken: C prints. Node prints A D B C. The simulator below does the same bookkeeping for a longer snippet.
The model
- Call stack. The list of functions currently executing. The rule is run-to-completion: a function that has started cannot be interrupted by another callback, so nothing queued elsewhere can run until the stack is empty.
- Microtask queue. First in, first out. Filled by
Promise.prototype.thenreactions (a reaction is the callback you gave to.then, waiting for its promise to be settled, meaning finished with a value or an error) and byqueueMicrotask. A.thencallback is never run synchronously, even on an already-resolved promise. - Macrotask queue. Here only timers: ordered by due time, ties by the order they were scheduled. Node documents that a
setTimeoutdelay below 1 ms is set to 1 ms, and that it gives no ordering guarantee for timers in general; the snippet below uses delays 0, 5, 10 and 12 so their order does not depend on that. - Microtask checkpoint. The moment when the runtime drains the microtask queue, meaning it runs microtasks one after another until none are left. MDN's microtask guide describes it: after a task exits and the stack is empty, all microtasks run, and microtasks queued by microtasks run before the next task.
The simulator and the check
EventLoopSim implements those rules, and runOnStack makes the run-to-completion rule executable: it throws if a callback is started while another frame is still on the stack (draining the microtask queue inside the script frame, for example, throws; the last check in the script below does exactly that and asserts the error). SimPromise is the smallest promise that shows the key behaviour: then always delivers its callback through the microtask queue, and the promise returned by then settles only after its callback has run, so a chained .then is queued later than microtasks that were already waiting. The script predicts the order by running the snippet on the simulator, then runs the identical snippet with the real APIs in a separate node process and asserts the two lists are equal. A control simulator that skips the checkpoint after each timer must produce a different list, otherwise the comparison would not be measuring anything.
import { execFileSync } from 'node:child_process';
import assert from 'node:assert/strict';
// A single-threaded event loop model: one call stack (run-to-completion),
// a microtask queue and a macrotask (timer) queue ordered by due time, then insertion order.
class EventLoopSim {
constructor({ drainMicrotasksAfterEachMacrotask = true } = {}) {
this.microtasks = [];
this.timers = []; // { due, seq, fn }
this.seq = 0;
this.now = 0; // virtual clock in ms
this.stack = []; // names of running frames, for illustration
this.log = [];
this.drainEach = drainMicrotasksAfterEachMacrotask;
}
print(msg) { this.log.push(msg); }
queueMicrotask(fn) { this.microtasks.push(fn); }
setTimeout(fn, delay = 0) { this.timers.push({ due: this.now + delay, seq: this.seq++, fn }); }
// Run one task to completion: nothing else can run until the stack is empty.
runOnStack(name, fn) {
if (this.stack.length) throw new Error(`${name} started while ${this.stack.at(-1)} is still running`);
this.stack.push(name);
try { fn(); } finally { this.stack.pop(); }
}
drainMicrotasks() {
while (this.microtasks.length) this.runOnStack('microtask', this.microtasks.shift());
}
runUntilIdle(mainScript) {
this.runOnStack('script', mainScript); // the script itself is the first macrotask
this.drainMicrotasks();
while (this.timers.length) {
this.timers.sort((a, b) => a.due - b.due || a.seq - b.seq);
const t = this.timers.shift();
this.now = t.due; // timers scheduled from here are relative to this time
this.runOnStack('timer', t.fn);
if (this.drainEach) this.drainMicrotasks(); // microtask checkpoint after every macrotask
}
if (!this.drainEach) this.drainMicrotasks(); // (only the deliberately broken variant gets here)
return this.log;
}
promiseResolve(value) { return new SimPromise(this, true, value); }
}
// Minimal promise: a then() callback is always delivered as a microtask, once the promise is settled.
class SimPromise {
constructor(loop, settled = true, value) { this.loop = loop; this.settled = settled; this.value = value; this.waiting = []; }
then(onFulfilled) {
const next = new SimPromise(this.loop, false);
const deliver = () => this.loop.queueMicrotask(() => {
next.value = onFulfilled(this.value);
next.settled = true;
next.waiting.forEach(w => w()); // wake the promise chained after this one
});
if (this.settled) deliver(); else this.waiting.push(deliver);
return next;
}
}
// The snippet written against the simulator's API.
function snippetOnSim(loop) {
return () => {
loop.print('script start');
loop.setTimeout(() => { // written first, but due later than the 0 ms timer
loop.print('timeout 10');
loop.setTimeout(() => loop.print('timeout inside timeout 10'), 5); // due at 10 + 5
}, 10);
loop.setTimeout(() => loop.print('timeout 12'), 12);
const first = loop.promiseResolve()
.then(() => { loop.print('promise 1'); loop.queueMicrotask(() => loop.print('nested microtask')); });
first.then(() => loop.print('promise 2'));
loop.setTimeout(() => {
loop.print('timeout 0');
loop.promiseResolve().then(() => loop.print('promise inside timeout 0'));
loop.queueMicrotask(() => loop.print('microtask inside timeout 0'));
first.then(() => loop.print('then on an already settled promise'));
}, 0);
loop.queueMicrotask(() => loop.print('microtask'));
loop.print('script end');
};
}
// The same snippet with the real APIs, run by a separate node process.
const realSnippet = `
console.log('script start');
setTimeout(() => {
console.log('timeout 10');
setTimeout(() => console.log('timeout inside timeout 10'), 5);
}, 10);
setTimeout(() => console.log('timeout 12'), 12);
const first = Promise.resolve()
.then(() => { console.log('promise 1'); queueMicrotask(() => console.log('nested microtask')); });
first.then(() => console.log('promise 2'));
setTimeout(() => {
console.log('timeout 0');
Promise.resolve().then(() => console.log('promise inside timeout 0'));
queueMicrotask(() => console.log('microtask inside timeout 0'));
first.then(() => console.log('then on an already settled promise'));
}, 0);
queueMicrotask(() => console.log('microtask'));
console.log('script end');
`;
const real = execFileSync(process.execPath, ['-e', realSnippet], { encoding: 'utf8' }).trim().split('\n');
const good = new EventLoopSim();
const predicted = good.runUntilIdle(snippetOnSim(good));
console.log('predicted:'); predicted.forEach((l, i) => console.log(`${i + 1}. ${l}`));
console.log('real node', process.version, 'matches prediction:', JSON.stringify(predicted) === JSON.stringify(real));
assert.deepEqual(predicted, real);
// Control: a loop that skips the microtask checkpoint between macrotasks must be caught by the same comparison.
const broken = new EventLoopSim({ drainMicrotasksAfterEachMacrotask: false });
const wrong = broken.runUntilIdle(snippetOnSim(broken));
assert.notDeepEqual(wrong, real);
console.log('control (no checkpoint after each macrotask) differs from real run:', JSON.stringify(wrong) !== JSON.stringify(real));
console.log('control output:'); wrong.forEach((l, i) => console.log(`${i + 1}. ${l}`));
// Guard check: starting a callback while another frame is still on the stack must throw.
const reentrant = new EventLoopSim();
reentrant.queueMicrotask(() => {});
assert.throws(
() => reentrant.runOnStack('script', () => reentrant.drainMicrotasks()),
/microtask started while script is still running/,
);
console.log('draining microtasks inside the script frame throws:', true);
Run in a node:22 container (Node v22.23.3) it prints:
predicted:
1. script start
2. script end
3. promise 1
4. microtask
5. nested microtask
6. promise 2
7. timeout 0
8. promise inside timeout 0
9. microtask inside timeout 0
10. then on an already settled promise
11. timeout 10
12. timeout 12
13. timeout inside timeout 10
real node v22.23.3 matches prediction: true
control (no checkpoint after each macrotask) differs from real run: true
control output:
1. script start
2. script end
3. promise 1
4. microtask
5. nested microtask
6. promise 2
7. timeout 0
8. timeout 10
9. timeout 12
10. timeout inside timeout 10
11. promise inside timeout 0
12. microtask inside timeout 0
13. then on an already settled promise
draining microtasks inside the script frame throws: true
How SimPromise works
Each SimPromise has four fields. loop is the simulator it queues its microtasks on. settled says whether the promise already has its value. value holds that value once it exists. waiting is a list of functions to call at the moment the promise becomes settled.
then(onFulfilled) does three things:
- It creates
next, the promise thatthenreturns.nextstarts unsettled, because its value is whateveronFulfilledreturns, and that has not run yet. - It builds
deliver, a function that puts a microtask on the queue. That microtask callsonFulfilled, stores the result innext.value, setsnext.settled = true, and then calls every function innext.waiting, which wakes any.thenchained afternext. - If this promise is already settled,
deliver()runs immediately, so the callback is queued as a microtask right away. If not,deliveris stored inthis.waitingand queued later.
Trace of first = p0.then(cb1) followed by first.then(cb2) from the snippet, where p0 is promiseResolve() (already settled), p1 is first (the promise returned by the first then) and p2 the promise returned by the second:
p0.then(cb1):p0is settled, so the job forcb1is queued now. Queue:[cb1 job].p1is unsettled.p1.then(cb2):p1is not settled, so the delivery ofcb2is parked inp1.waiting. Queue unchanged:[cb1 job].loop.queueMicrotask(...)formicrotask. Queue:[cb1 job, microtask].- The checkpoint runs
cb1 job. It printspromise 1and queuesnested microtask(queue:[microtask, nested microtask]), marksp1settled, and callsp1.waiting, which queues thecb2job. Queue:[microtask, nested microtask, cb2 job]. - The remaining three jobs print
microtask,nested microtaskandpromise 2, in that order.
What the control run shows
The control loop skips the checkpoint after each timer and drains microtasks only once, at the very end. It prints the list above. Lines 1 to 7 are identical to the real run. The difference starts at line 8: timeout 10, timeout 12 and timeout inside timeout 10 all run before promise inside timeout 0, microtask inside timeout 0 and then on an already settled promise, because those three microtasks sat in the queue through the later timers instead of running right after the first one. The assertion notDeepEqual(wrong, real) therefore passes only because the comparison can detect a loop that gets the checkpoint wrong.
Other parts of the model were checked the same way, by deleting one line at a time and re-running: deleting the if (this.stack.length) throw guard in runOnStack makes the last assertion fail, and dropping the sort by due time, the this.now = t.due update, the settled flag, the wake-up of next.waiting or the if (this.settled) branch in then each makes the real-versus-predicted comparison fail. The script lists the 10 ms timer before the 0 ms timer so the sort is observable, and the timer created inside timeout 10 with delay 5 is due at 15, after the 12 ms timer, which only works if the clock moves to a timer's due time before its callback runs. Deleting the seq tie-break does not change the output, because Array.prototype.sort is stable in current engines; seq only makes the tie rule explicit.
Reading the order
- Lines 1 and 2: the script runs on the stack; the timers, the promise reaction and the microtask are only scheduled.
- Lines 3 and 4: the microtask queue holds
[promise 1 callback, microtask]in scheduling order, sopromise 1runs first. - Line 5:
promise 1queuednested microtaskwhile it ran, so that is added to the queue beforepromise 2, whose reaction is queued only when thepromise 1callback returns. Lines 5 and 6 are the part people get wrong:promise 2is not "next" just because it is chained. - Lines 7 to 10: the stack and microtask queue are empty, so the first timer runs. The 0 ms timer was written last in the script but is due first, so it runs before the 10 ms and 12 ms timers and prints
timeout 0. While it runs it queues three jobs:Promise.resolve().then(...)queues its callback at once because that promise is already settled,queueMicrotaskqueues the second, andfirst.then(...)on the already settledfirstqueues the third. Microtask queue:[promise inside timeout 0, microtask inside timeout 0, then on an already settled promise]. The timer callback returns, and the checkpoint empties that queue in that order. - Lines 11 to 13: the remaining timers in due order.
timeout 10runs and schedules a 5 ms timer, which is due at 10 + 5 = 15, sotimeout 12(due at 12) goes first andtimeout inside timeout 10last.
Pitfalls and trade-offs
- A real engine also has
await(waits for a promise inside an async function, resuming through the microtask queue),setImmediate(a Node function that runs a callback in the loop phase right after I/O polling), I/O phases (the stretches of each Node loop iteration that handle file and network callbacks) and rendering steps (the browser's style, layout and paint work between tasks); the model above covers only the three queues the question names. Node and a browser can differ for timer versus I/O ordering, which is why the comparison names the runtime it ran on. - The simulator's virtual clock only orders timers; it does not wait. A timer created inside a timer is due at that timer's due time plus its delay.
- A microtask that always queues another microtask starves timers and rendering forever (the queue never becomes empty, so the loop never moves on to the next macrotask or paint); this is the one real hazard of the "drain completely" rule.
- Do not use timer order as program logic: Node's docs state no ordering guarantee.
Running the code
mkdir -p demo && cp loop_sim.mjs demo/ && cd demo
docker run --rm --ulimit core=0 -v "$PWD":/w -w /w node:22 node loop_sim.mjs
Save the block above as loop_sim.mjs. No install step or config is needed; .mjs makes it an ES module.
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
Predict the console output of the snippet below, line by line, and explain what decides each position. Pay attention to the timer that is created from inside a promise callback.
console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve().then(() => console.log('C'));
Promise.resolve().then(() => { console.log('D'); setTimeout(() => console.log('E'), 0); });
console.log('F');
Sample Answer
Direct answer
The output is A F C D B E. A and F are synchronous, so they print first. The two promise callbacks (C, then D) are microtasks and run right after the script ends, before any timer. B and E are both timer callbacks with a delay of 0; B's timer was registered first (during the script) and E's second (inside D), so B runs before E.
What decides each position
| Position | Output | Deciding rule |
|---|---|---|
| 1 | A | Synchronous code in the script runs top to bottom |
| 2 | F | Still the same script: setTimeout and .then only schedule, they do not run anything |
| 3 | C | First promise reaction queued, so first in the microtask queue (the queue for promise callbacks, drained as soon as the script finishes) |
| 4 | D | Second promise reaction. While running, it registers a new timer for E |
| 5 | B | The microtask queue is now empty, so the loop takes the next task: the timer registered first |
| 6 | E | The timer created inside D, registered after B with the same delay, so it runs after B |
The timer created from inside a promise callback is the trap. A reader may think E belongs "with the promises" and place it before B. It does not: creating a timer inside a microtask does not run it in the microtask. The timer just joins the timer queue, behind the one that already existed.
Check it, and change one number
Timers are ordered by when they become due, and by registration order when the due time is equal. Raising B's delay to 10 ms should therefore move E ahead of B. The script below runs both versions and fails (exit code 1) if either order differs from the prediction.
// The snippet from the question, run as written, then the same code with B's delay raised to 10 ms.
const order = [];
const run = (bDelay) => new Promise((done) => {
const out = [];
const log = (m) => out.push(m);
log('A');
setTimeout(() => log('B'), bDelay);
Promise.resolve().then(() => log('C'));
Promise.resolve().then(() => { log('D'); setTimeout(() => log('E'), 0); });
log('F');
setTimeout(() => done(out.join(' ')), 50);
});
const asWritten = await run(0);
const slowerB = await run(10);
console.log('B delay 0 :', asWritten);
console.log('B delay 10:', slowerB);
if (asWritten !== 'A F C D B E' || slowerB !== 'A F C D E B') process.exit(1);
Run in a Node 22 container, it prints:
B delay 0 : A F C D B E
B delay 10: A F C D E B
Reading the script: run(bDelay) executes the question's snippet once and returns a promise. log pushes each label into out instead of printing, so nothing interleaves with the harness. The last line of run starts a 50 ms timer that resolves the promise with the collected labels joined by spaces; 50 ms is long enough for B (at most 10 ms) and E (0 ms) to have fired first. The top-level await calls (allowed in an ES module, hence the .mjs file below) run the two versions one after the other, and the final if exits with code 1 if either order differs from the prediction.
The ordering comes from the microtask rule and the timer queue, so a browser page produces the same sequence for this snippet.
Pitfalls
- Assuming "0 ms" timers run in a burst with the microtasks. A timer callback is always a separate task after the microtask queue has emptied.
- Assuming a timer registered later with a smaller delay runs later. Ordering is by due time first; only ties fall back to registration order. This is why the
Bdelay experiment flips the result. - Expecting nested microtasks to look different here. If
Dhad queued another promise reaction, it would also run beforeB, because the queue must be empty first. - Treating small delay values as precise. A busy main thread delays timers, so a delay is a minimum wait, not a schedule.
Running the code
# save the script above as order-check.mjs (.mjs enables top-level await)
docker run --rm -v "$PWD":/w -w /w node:22 node order-check.mjs
Take one asynchronous operation that can fail and show it written three ways: as nested callbacks, as a promise chain, and with async/await. For each version, explain how an error travels and where it would be lost if you were careless, and which version you would want to maintain.
Sample Answer
Direct answer
I would maintain the async/await version. All three can carry an error to the caller, but they differ in what you must do by hand. With nested callbacks every level has to check and forward the error itself, and a thrown exception cannot be caught from outside. With a promise chain, a rejection skips forward to the nearest .catch, but a missing return silently detaches part of the chain. With async/await the same rejection becomes an ordinary exception caught by try/catch, and the control flow reads top to bottom.
One operation, three ways
The operation is "load a user, then load that user's orders"; either step can fail. The legacy functions use the error-first callback convention (cb(err, value): the callback's first argument is the error or null, the second is the result). util.promisify turns such callback functions into promise-returning ones, which is the usual migration path, and the program uses it for the promise and async/await versions. The program also uses top-level await (allowed in an ES module, so await can appear outside a function) and Node's two process-wide safety nets: the unhandledRejection event, which fires when a promise is rejected and nothing is attached to handle it, and the uncaughtException event, which fires when an exception travels all the way up the stack with no catch.
import { promisify } from 'node:util';
// The operation: asynchronous, can fail. Error-first callback style.
function loadUser(id, cb) {
setTimeout(() => (id === 0 ? cb(new Error('user ' + id + ' not found')) : cb(null, { id, name: 'user' + id })), 1);
}
function loadOrders(user, cb) {
setTimeout(() => (user.id === 2 ? cb(new Error('orders service down')) : cb(null, [user.name + '-order'])), 1);
}
const loadUserP = promisify(loadUser);
const loadOrdersP = promisify(loadOrders);
// 1. Nested callbacks: every level must forward err itself.
function viaCallbacks(id, done) {
loadUser(id, (err, user) => {
if (err) return done(err);
loadOrders(user, (err2, orders) => {
if (err2) return done(err2);
done(null, orders);
});
});
}
// 2. Promise chain: one catch at the end sees every earlier failure.
function viaPromises(id) {
return loadUserP(id).then((user) => loadOrdersP(user)); // returning the inner promise keeps the chain connected
}
// 3. async/await: ordinary try/catch.
async function viaAwait(id) {
const user = await loadUserP(id);
return await loadOrdersP(user);
}
const results = [];
const note = (m) => results.push(m);
const cbResult = (id) => new Promise((res) => viaCallbacks(id, (e, v) => res(e ? 'error: ' + e.message : 'ok: ' + v)));
for (const id of [1, 0, 2]) {
note(`id ${id} callbacks -> ` + await cbResult(id));
note(`id ${id} promises -> ` + await viaPromises(id).then((v) => 'ok: ' + v, (e) => 'error: ' + e.message));
try { note(`id ${id} await -> ok: ` + await viaAwait(id)); } catch (e) { note(`id ${id} await -> error: ` + e.message); }
}
console.log(results.join('\n'));
// Where careless code loses the error
const lost = [];
process.on('unhandledRejection', (r) => lost.push('unhandledRejection: ' + r.message));
process.on('uncaughtException', (e) => lost.push('uncaughtException: ' + e.message));
// callbacks: a throw inside an async callback skips the surrounding try/catch
try {
loadUser(1, () => { throw new Error('thrown in callback'); });
} catch (e) { lost.push('outer catch saw it (it should not)'); }
// promises: forgetting `return` detaches the inner promise from the chain
Promise.resolve().then(() => { loadUserP(0); }).then(() => lost.push('chain continued as if fine'));
// async: calling without await in the try block
async function forgot() { try { loadUserP(0); } catch (e) { lost.push('caught (it should not be)'); } }
forgot();
await new Promise((r) => setTimeout(r, 50));
console.log(lost.sort().join('\n'));
console.log('every careless case surfaced only as a global event:', lost.filter(s => s.startsWith('outer') || s.startsWith('caught')).length === 0);
Run in a Node 22 container, it prints:
id 1 callbacks -> ok: user1-order
id 1 promises -> ok: user1-order
id 1 await -> ok: user1-order
id 0 callbacks -> error: user 0 not found
id 0 promises -> error: user 0 not found
id 0 await -> error: user 0 not found
id 2 callbacks -> error: orders service down
id 2 promises -> error: orders service down
id 2 await -> error: orders service down
chain continued as if fine
uncaughtException: thrown in callback
unhandledRejection: user 0 not found
unhandledRejection: user 0 not found
every careless case surfaced only as a global event: true
The first nine lines show all three styles give the same results for a success, a failure at step one, and a failure at step two. The next four lines come from the careless block, which sorts them alphabetically, so they are not in the order they happened. Each maps to one mistake:
chain continued as if fine: the promise-chain mistake (.then(() => { loadUserP(0); })). The step returned nothing, so the next.thenran straight away as if the step had succeeded.uncaughtException: thrown in callback: the callback mistake. Thethrowhappened after the surroundingtry/catchhad already finished, so it reached the top of the stack and fired the process-wide event.- two
unhandledRejection: user 0 not foundlines: one from the dropped promise in the chain mistake and one from the missingawaitinforgot(). Both promises rejected with no handler attached.
How an error travels in each version
| Style | Path of the error | Who is responsible for forwarding it |
|---|---|---|
| Nested callbacks | Passed as the first argument of each callback | You, at every level: if (err) return done(err) |
| Promise chain | A rejection skips every .then until the next .catch (or the second argument of a .then) | The promise machinery, as long as each .then returns its inner promise |
async/await | await throws the rejection as an exception inside the async function | try/catch; if you do not catch, the returned promise rejects and the caller deals with it |
Where the error gets lost if you are careless
The last block of the program triggers one careless mistake per style. The four lines after the results show that none of the mistakes reached the local error handling. Each surfaced only as a process-wide event (one uncaughtException, two unhandledRejections) or not at all ("chain continued as if fine"), which in production is a crash or a log line far from the cause:
- Callbacks: a
throwinside an asynchronous callback is not caught by atry/catcharound the call that started the work, because thattryblock has already finished by the time the callback runs. The exception becomes anuncaughtExceptionfor the whole process. Also, forgettingreturnafterdone(err)makes the code continue into the success path; forgetting to checkerrat all swallows it. - Promise chain:
.then(() => { loadUserP(0); })starts a promise but does not return it (a "floating" promise: started, with nothing waiting on it), so the chain is detached from that step and continues as if the step succeeded, and the rejection has no handler (unhandledRejection). With the fix the same line reads.then(() => loadUserP(0)), and the chain waits for the step and its error flows to the next.catch. In Node's default--unhandled-rejections=throwmode (the command-line setting that decides what Node does with an unhandled rejection), an unhandled rejection is raised as an uncaught exception, which ends the process unless you handle it. Alwaysreturnthe inner promise. - async/await: calling an async function without
awaitinside atrymeans thecatchnever sees the failure; the rejection is again unhandled. The same happens withreturn somePromiseinsidetry(usereturn awaitwhen you need the localcatchto apply).
Which to maintain, and why
async/await wins on readability (flat code, one error-handling mechanism, normal loops and conditionals), on stack traces (the list of calls shown with an error; async stack traces through await are generally easier to follow than through callbacks), and on how little each step must remember. Keep callbacks only at the edge of a legacy API and wrap them once with new Promise or util.promisify. Choose a promise chain over await only for short, linear pipelines or when composing with combinators such as Promise.all (helpers that combine several promises into one). What would flip this: a hot path (code run extremely often, where small per-call costs add up) where creating a promise per event matters more than clarity, or a library whose API is callback-only and cannot be wrapped.
Complexity and edge cases
- Complexity: each version does two sequential awaits, so latency is the sum of the two operations; time is O(1) steps and no extra memory beyond the closures or promises. If the two calls were independent,
Promise.allwould make latency the maximum instead of the sum. - Edge cases: failure of the first step must not start the second; the callback must be called exactly once (a common bug is writing
if (err) done(err);withoutreturn:donereceives the error, execution falls through into the success path, anddonecan then be called a second time or run with a missing value); an error thrown synchronously before the first await in anasyncfunction still becomes a rejection, not a throw.
Running the code
# save the program as errors.mjs (.mjs enables import and top-level await)
docker run --rm --ulimit core=0 -v "$PWD":/w -w /w node:22 node errors.mjs
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.
Unlock Full Question Bank
Get access to all 10 Asynchronous and Event-Driven Programming interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.