Event Loop — Tricky Interview Questions
These are the kinds of questions that trip up even experienced developers. Read each one, think about the output before reading the answer, and understand why.
Q1: Classic Output Prediction
javascriptconsole.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');Output: 1 4 3 2
Why:
1→ syncsetTimeout→ macrotask queuePromise.then→ microtask queue4→ sync- Stack empty → drain microtasks →
3 - Pick macrotask →
2
Q2: Nested Promises
javascriptPromise.resolve()
.then(() => {
console.log('A');
return Promise.resolve('B');
})
.then(v => console.log(v));
Promise.resolve()
.then(() => console.log('C'))
.then(() => console.log('D'));Output: A C D B
Why this is tricky: return Promise.resolve('B') introduces an extra microtask tick. When you return a thenable from .then(), JS has to:
- Resolve the outer promise with the inner promise
- Subscribe to the inner promise — this costs 2 extra microtask ticks
So the chain looks like:
- Microtask tick 1: A fires, C fires
- Microtask tick 2: D fires, "B-inner" fires (registering follow-up)
- Microtask tick 3: B fires
Lesson: return Promise.resolve(x) inside .then() is NOT the same as return x — it adds microtask overhead.
Q3: async/await Order
javascriptasync function foo() {
console.log('foo start');
await bar();
console.log('foo end');
}
async function bar() {
console.log('bar');
}
console.log('before');
foo();
console.log('after');Output: before foo start bar after foo end
Why:
before→ syncfoo()called →foo start→ sync inside async functionawait bar()→ callsbar()→barlogs → then suspendsfoo, schedulesfoo endas microtaskafter→ sync (foo is suspended, not blocking)- Stack empty → microtask:
foo end
Q4: process.nextTick vs Promise
javascriptprocess.nextTick(() => console.log('nextTick 1'));
Promise.resolve().then(() => console.log('promise 1'));
process.nextTick(() => console.log('nextTick 2'));
Promise.resolve().then(() => console.log('promise 2'));Output: nextTick 1 nextTick 2 promise 1 promise 2
Why: process.nextTick queue is processed BEFORE the Promise microtask queue. Both are "microtasks" conceptually, but Node.js has two queues: nextTick queue (higher) and promise microtask queue (lower).
Q5: The Infinite nextTick Trap
javascriptlet count = 0;
function recurse() {
if (count < 3) {
count++;
process.nextTick(recurse);
}
}
process.nextTick(recurse);
setTimeout(() => console.log('timeout'), 0);Output: timeout (after 3 recursions worth of nextTicks)
Actually wait — count < 3 limits it, so nextTicks finish and timeout runs.
Now this version:
javascriptfunction endless() {
process.nextTick(endless);
}
endless();
setTimeout(() => console.log('never'), 0);
// setTimeout NEVER fires — nextTick queue never emptiesLesson: Recursive process.nextTick starves the event loop. Use setImmediate for recursive async patterns.
Q6: setImmediate vs setTimeout Outside I/O
javascriptsetTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));Output: Non-deterministic! Could be either order.
Why: When this runs outside an I/O callback, the order depends on system timer resolution and when the event loop checks timers vs check phase. This is a well-known Node.js quirk.
BUT inside an I/O callback:
javascriptconst fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
// Output: ALWAYS "immediate" then "timeout"
// Inside I/O: we're in poll phase, next is check phase (setImmediate)Q7: Promise Constructor is Synchronous
javascriptconsole.log('1');
const p = new Promise((resolve) => {
console.log('2'); // executor runs synchronously!
resolve('done');
console.log('3'); // still runs — after resolve()
});
p.then(v => console.log('4:', v));
console.log('5');Output: 1 2 3 5 4: done
Why: The Promise executor function runs synchronously. resolve() doesn't immediately run .then() callbacks — those are microtasks. Code after resolve() still runs.
Q8: Chained then Return Values
javascriptPromise.resolve(1)
.then(x => x + 1) // returns 2
.then(x => {
throw new Error('oops');
})
.then(x => console.log('then:', x)) // skipped!
.catch(e => {
console.log('catch:', e.message);
return 'recovered';
})
.then(x => console.log('after catch:', x));Output:
catch: oops
after catch: recoveredWhy: When .then() throws, the error propagates down the chain, skipping all .then() handlers until it hits .catch(). After .catch() returns normally, the chain continues with .then().
Q9: Async forEach Trap
javascriptasync function processItems() {
const items = [1, 2, 3];
items.forEach(async (item) => {
await delay(item * 100);
console.log(item);
});
console.log('done');
}Output: done 1 2 3 (done appears BEFORE the items!)
Why: Array.forEach does NOT await async callbacks. Each async callback is called but immediately returns a Promise that forEach ignores. The done log runs synchronously after forEach returns.
Fix:
javascript// Option 1: for...of (sequential)
for (const item of items) {
await delay(item * 100);
console.log(item);
}
// Option 2: Promise.all (parallel)
await Promise.all(items.map(async (item) => {
await delay(item * 100);
console.log(item);
}));Q10: Microtask Between Each Await
javascriptasync function test() {
console.log('A');
await 1;
console.log('B');
await 2;
console.log('C');
}
test();
Promise.resolve().then(() => console.log('D'));
console.log('E');Output: A E B D C
Step-by-step:
test()called →A(sync)await 1→ suspends, schedules resume as microtaskDcallback → queued as microtaskE→ sync- Stack empty → drain microtasks:
- Resume test →
B await 2→ suspends again, schedules resumeDcallback runs →D- Resume test →
C
- Resume test →
Q11: Error in Async Function Without Await
javascriptasync function fail() {
throw new Error('async error');
}
fail(); // No await, no .catch()
// Does this crash the process?Answer: It does NOT crash synchronously. It returns a rejected Promise. In newer Node.js (v15+), an unhandled rejection WILL crash the process by default. In older versions, it just emits a warning.
javascript// Safe pattern:
fail().catch(err => console.error('Caught:', err.message));
// Or:
process.on('unhandledRejection', (reason) => {
console.error('Unhandled rejection:', reason);
process.exit(1);
});Q12: The Classic Timer Loop Bug
javascriptfor (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}Output: 3 3 3 (NOT 0 1 2!)
Why: var is function-scoped, not block-scoped. All three closures reference the SAME i. By the time the callbacks run, the loop has finished and i === 3.
Fixes:
javascript// Fix 1: let (block-scoped — each iteration gets own i)
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0); // 0 1 2
}
// Fix 2: IIFE to capture value
for (var i = 0; i < 3; i++) {
((j) => setTimeout(() => console.log(j), 0))(i); // 0 1 2
}
// Fix 3: bind
for (var i = 0; i < 3; i++) {
setTimeout(console.log.bind(null, i), 0); // 0 1 2
}Q13: Multiple Awaits on Same Promise
javascriptconst p = new Promise(resolve => setTimeout(() => resolve('done'), 1000));
async function a() {
const result = await p;
console.log('a:', result);
}
async function b() {
const result = await p;
console.log('b:', result);
}
a();
b();Output (after 1 second): a: done then b: done (both receive the value)
Why: Promises are multicast. Multiple .then() handlers (and awaits) on the same promise all get notified when it settles. The value is not consumed.
Q14: queueMicrotask vs Promise.resolve().then()
javascriptqueueMicrotask(() => console.log('A'));
Promise.resolve().then(() => console.log('B'));
queueMicrotask(() => console.log('C'));Output: A B C
They share the same microtask queue! queueMicrotask and Promise.resolve().then() are equivalent in terms of timing. Order is insertion order.
Q15: Synchronous Promise Resolution Chain Length
javascriptlet resolveOuter;
const outer = new Promise(res => resolveOuter = res);
outer
.then(() => console.log('1'))
.then(() => console.log('2'))
.then(() => console.log('3'));
resolveOuter();
console.log('sync');Output: sync 1 2 3
Each .then() in a chain requires its own microtask tick. A 3-step chain takes 3 microtask ticks.
Q16: Event Loop Starvation via Sync Code
javascriptsetTimeout(() => console.log('timeout'), 0);
// Synchronous operation that takes 2 seconds
const end = Date.now() + 2000;
while (Date.now() < end) {} // busy wait
console.log('sync done');Output: sync done then (after 2s total) timeout
Lesson: Synchronous code always runs to completion. The event loop cannot intervene mid-execution.
Q17: Promise.all Failure Behavior
javascriptconst p1 = Promise.resolve('success');
const p2 = new Promise((_, reject) => setTimeout(() => reject('error'), 100));
const p3 = new Promise(resolve => setTimeout(() => resolve('late'), 200));
Promise.all([p1, p2, p3])
.then(results => console.log('results:', results))
.catch(err => console.log('error:', err));Output (after 100ms): error: error
Why: Promise.all short-circuits on first rejection. p3 is still pending but we get the error. p3's eventual resolution is ignored.
Q18: Tricky async Return
javascriptasync function getVal() {
return 42;
}
const result = getVal();
console.log(result); // What is this?
console.log(result === 42); // true or false?Output:
Promise { 42 }
falseWhy: async functions ALWAYS return a Promise. The value 42 is the resolved value, not the return value directly. You need await getVal() or .then() to get 42.
Q19: try/catch with async Doesn't Always Catch
javascriptasync function bad() {
setTimeout(() => {
throw new Error('from setTimeout'); // NOT caught!
}, 100);
}
async function test() {
try {
await bad();
} catch (e) {
console.log('caught:', e); // This does NOT run!
}
}
test();Why: bad() returns a resolved Promise immediately. The setTimeout callback throws later, in a completely different call context. The try/catch can only catch errors thrown synchronously or from awaited Promises.
Fix: Wrap the error in a promise:
javascriptasync function better() {
return new Promise((_, reject) => {
setTimeout(() => reject(new Error('from setTimeout')), 100);
});
}Q20: What Does the Event Loop Do When Idle?
When both queues are empty and no timers are pending, Node.js:
- Enters poll phase and waits for I/O events
- Goes to sleep (OS level)
- Wakes up when an event arrives (timer expiry, network data, etc.)
This is why a Node.js server process stays alive — it's waiting in the poll phase for incoming connections, not spinning in a loop.
javascript// This process exits immediately (no pending work)
console.log('done');
// This process stays alive (has a pending I/O operation)
const server = require('net').createServer().listen(3000);Q21: Promise Created Inside setTimeout
javascriptsetTimeout(() => {
Promise.resolve()
.then(() => console.log('microtask inside macrotask'));
console.log('macrotask body');
}, 0);
setTimeout(() => console.log('second macrotask'), 0);Output:
macrotask body
microtask inside macrotask
second macrotaskWhy: After each macrotask runs, microtasks drain. So the microtask created inside the first setTimeout runs BEFORE the second setTimeout callback.
Q22: Promise.allSettled vs Promise.all vs Promise.race vs Promise.any
javascriptconst fast = new Promise(resolve => setTimeout(() => resolve('fast'), 100));
const slow = new Promise(resolve => setTimeout(() => resolve('slow'), 300));
const fail = new Promise((_, reject) => setTimeout(() => reject('error'), 200));
// Promise.all — rejects as soon as ANY rejects:
Promise.all([fast, fail, slow])
.catch(e => console.log('all:', e));
// Output: 'all: error' (after 200ms)
// Promise.allSettled — waits for ALL, never rejects:
Promise.allSettled([fast, fail, slow])
.then(results => console.log(results));
// Output after 300ms:
// [
// { status: 'fulfilled', value: 'fast' },
// { status: 'rejected', reason: 'error' },
// { status: 'fulfilled', value: 'slow' }
// ]
// Promise.race — settles with first to settle (win OR fail):
Promise.race([fast, fail, slow])
.then(v => console.log('race:', v));
// Output: 'race: fast' (after 100ms — first to settle)
// Promise.any — resolves with first to FULFILL (ignores rejects):
Promise.any([fail, fast, slow])
.then(v => console.log('any:', v));
// Output: 'any: fast' (after 100ms — first to fulfill)
// If ALL reject → AggregateErrorCheat sheet:
| Method | Resolves | Rejects |
|---|---|---|
all |
all fulfilled | first rejection |
allSettled |
always (with statuses) | never |
race |
first to settle (any) | first to settle (rejection) |
any |
first fulfilled | all rejected → AggregateError |
Q23: Recursive setTimeout vs setInterval
javascript// setInterval — fixed gap from start of last execution:
const id = setInterval(() => {
console.log('interval');
// if this takes 150ms and interval is 100ms:
// next call fires immediately after this one finishes (can stack up)
}, 100);
// Recursive setTimeout — fixed gap AFTER completion:
function tick() {
console.log('tick');
// next call is exactly 100ms AFTER this completes
setTimeout(tick, 100);
}
setTimeout(tick, 100);When to prefer recursive setTimeout:
- When the callback takes variable time (no overlapping calls)
- When you need to adjust delay dynamically
- When you want to stop the loop cleanly from inside
Q24: What Happens When You clearTimeout an Expired Timer?
javascriptconst id = setTimeout(() => console.log('fired'), 0);
// The timer fires almost immediately.
// Then later:
setTimeout(() => {
clearTimeout(id); // clearing an already-fired timer
console.log('cleared (but already fired)');
}, 100);Output: 'fired' then 'cleared (but already fired)'
clearTimeout on an already-fired or non-existent timer ID is a no-op. It doesn't throw. It's safe to always call clearTimeout(id) in cleanup even if you're not sure whether it fired.
Q25: for await...of with Async Iterators
javascriptasync function* generate() {
yield 1;
await new Promise(resolve => setTimeout(resolve, 100));
yield 2;
yield 3;
}
async function main() {
console.log('start');
for await (const value of generate()) {
console.log(value);
}
console.log('end');
}
main();
console.log('after main call'); // runs before 'start' completes?Output:
start
after main call
1
2
3
endmain() is async — for await...of suspends at each await inside the generator. Code after main() runs while main is suspended (it returned a pending Promise immediately).
Q26: Microtask Flooding — Too Many Promises
javascriptlet count = 0;
function floodMicrotasks() {
if (count < 1000000) {
count++;
Promise.resolve().then(floodMicrotasks); // queue a microtask
}
}
floodMicrotasks();
setTimeout(() => console.log('macrotask — count:', count), 0);Output: 'macrotask — count: 1000000' (but possibly after a long delay)
Microtasks drain completely before any macrotask runs. A million microtasks will all run before the setTimeout fires. This is why flooding microtasks starves I/O.
Compare: process.nextTick has same behavior (but even higher priority).
Q27: Async Function Called Without await — When Does It Run?
javascriptasync function fetchData() {
console.log('A');
const result = await Promise.resolve('data');
console.log('B', result);
return result;
}
console.log('1');
const promise = fetchData(); // no await
console.log('2');
promise.then(v => console.log('3', v));
console.log('4');Output: 1 A 2 4 B data 3 data
Step by step:
1syncfetchData()called →Async, hitsawait, suspends2sync.then(...)registered on promise4sync- Microtasks drain: resume fetchData →
B data, returns'data' - Chained
.then→3 data
Q28: Event Loop with Real I/O — Order Guarantee
javascriptconst fs = require('fs');
fs.readFile(__filename, () => {
console.log('readFile callback'); // poll phase
setTimeout(() => console.log('timeout inside I/O'), 0);
setImmediate(() => console.log('immediate inside I/O'));
process.nextTick(() => console.log('nextTick inside I/O'));
Promise.resolve().then(() => console.log('promise inside I/O'));
});
setTimeout(() => console.log('timeout outside I/O'), 0);
setImmediate(() => console.log('immediate outside I/O'));Output (guaranteed order):
immediate outside I/O ← or timeout outside (indeterminate)
readFile callback
nextTick inside I/O ← highest priority
promise inside I/O ← second
immediate inside I/O ← check phase (next)
timeout inside I/O ← timers phase (after check)Inside an I/O callback: nextTick > Promise > setImmediate > setTimeout.
Quick Reference: Execution Order Rules
1. ALL synchronous code
↓
2. process.nextTick queue (drained completely) [Node.js only]
↓
3. Promise microtask queue (drained completely)
↓
4. ONE macrotask (setTimeout/setInterval/setImmediate/I/O)
↓
5. Go back to step 2Memory trick: "Sync → Next → Promise → Macro → repeat"
Quick Reference: Promise Combinators
| Method | Waits for | Short-circuits on | Returns |
|---|---|---|---|
Promise.all(arr) |
all | first rejection | array of values |
Promise.allSettled(arr) |
all | never | array of {status,value/reason} |
Promise.race(arr) |
first | first settlement | single value/rejection |
Promise.any(arr) |
first fulfill | all reject | single value / AggregateError |
Q29: queueMicrotask Inside queueMicrotask vs Promise Chain
javascriptqueueMicrotask(() => {
console.log('A');
queueMicrotask(() => console.log('B'));
});
Promise.resolve()
.then(() => console.log('C'))
.then(() => console.log('D'));Output: A C B D
Why: Both queueMicrotask and Promise.resolve().then() share the same microtask queue in FIFO order.
- Tick 1: outer
queueMicrotaskruns →A, queuesB; then.then(C)runs →C, queuesD - Tick 2:
Bruns, thenDruns
Lesson: Nested microtasks are appended to the end of the current microtask queue, interleaving with other pending microtasks.
Q30: queueMicrotask Error vs Promise Rejection
javascriptqueueMicrotask(() => {
throw new Error('qMT error');
});
Promise.resolve().then(() => {
throw new Error('promise error');
});What happens:
queueMicrotaskthrow → uncaught exception (like any synchronous throw). In Node.js this crashes the process.Promise.thenthrow → becomes a rejected promise → triggersunhandledRejectionhandler.
Lesson: queueMicrotask and Promise.resolve().then() are NOT identical. Errors from queueMicrotask bypass Promise rejection handling entirely. This matters for error recovery strategies.
Q31: requestAnimationFrame vs Microtask vs setTimeout (Browser)
javascript// Browser only
console.log('sync');
requestAnimationFrame(() => console.log('rAF'));
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('microtask'));
queueMicrotask(() => console.log('qMT'));Output:
sync
microtask
qMT
rAF ← or after timeout, depends on browser
timeout ← or before rAFWhy: Microtasks always run first. But rAF vs setTimeout(0) ordering is NOT guaranteed. rAF fires before the next repaint (typically ~16ms at 60fps). setTimeout(0) fires on the next macrotask. In Chrome, setTimeout(0) usually fires before rAF because the timer resolves before the next frame.
Key insight: rAF is NOT a microtask and NOT a regular macrotask — it has its own queue processed once per frame, after macrotasks and before paint.
Q32: requestAnimationFrame Creates Microtasks
javascript// Browser only
requestAnimationFrame(() => {
console.log('rAF 1');
Promise.resolve().then(() => console.log('microtask in rAF'));
});
requestAnimationFrame(() => {
console.log('rAF 2');
});Output:
rAF 1
microtask in rAF
rAF 2Why: Microtasks drain between each rAF callback, just like they drain between each macrotask. This means a microtask queued inside one rAF callback runs BEFORE the next rAF callback.
Q33: process.nextTick Inside a Promise vs Promise Inside nextTick
javascriptPromise.resolve().then(() => {
console.log('P1');
process.nextTick(() => console.log('NT inside P'));
});
process.nextTick(() => {
console.log('NT1');
Promise.resolve().then(() => console.log('P inside NT'));
});Output: NT1 P inside NT P1 NT inside P
Why: Step-by-step:
- nextTick queue drains first:
NT1, which queues a promise microtask - Promise microtask queue drains:
P inside NT(queued by NT1), thenP1(original), which queues a nextTick - nextTick queue drains again:
NT inside P
Key insight: After each microtask phase, Node checks the nextTick queue again before moving on. The two queues interleave: nextTick → promises → check nextTick again → check promises again → until both empty.
Q34: Multiple Nested setTimeout(0) — Timing Accumulates
javascriptconsole.time('total');
setTimeout(() => {
console.log('1');
setTimeout(() => {
console.log('2');
setTimeout(() => {
console.log('3');
console.timeEnd('total');
}, 0);
}, 0);
}, 0);Output: 1 then 2 then 3 then total: ~3-12ms (browser may clamp to 4ms each)
Why: In browsers, nested setTimeout(0) calls beyond depth 4 get clamped to a minimum of 4ms. Three nested levels ≈ 12ms minimum. In Node.js, there's no such clamping — setTimeout(0) becomes setTimeout(1) but nested calls still run in consecutive event loop iterations with minimal delay.
This is why setTimeout(0) is NOT a reliable way to schedule tight loops.
Q35: setTimeout(0) vs setTimeout(0) vs setTimeout(0) — FIFO?
javascriptsetTimeout(() => console.log('A'), 0);
setTimeout(() => console.log('B'), 0);
setTimeout(() => console.log('C'), 0);Output: A B C — always
Why: Multiple setTimeout(0) calls at the same level are FIFO. They all expire in the same timer phase and are processed in registration order. This is guaranteed by the spec.
BUT this is NOT FIFO:
javascriptsetTimeout(() => console.log('X'), 10);
setTimeout(() => console.log('Y'), 0);
setTimeout(() => console.log('Z'), 5);Output: Y Z X — sorted by expiration time, not registration order.
Q36: async/await in for...of vs forEach vs for Loop
javascriptconst delay = ms => new Promise(r => setTimeout(r, ms));
async function sequential() {
const items = [300, 100, 200];
console.time('for-of');
for (const ms of items) {
await delay(ms);
}
console.timeEnd('for-of'); // ~600ms (sequential)
}
async function parallel() {
const items = [300, 100, 200];
console.time('map');
await Promise.all(items.map(ms => delay(ms)));
console.timeEnd('map'); // ~300ms (parallel, limited by slowest)
}
async function broken() {
const items = [300, 100, 200];
console.time('forEach');
items.forEach(async ms => {
await delay(ms);
});
console.timeEnd('forEach'); // ~0ms!! forEach doesn't await
}Lesson: for...of = sequential, Promise.all(map) = parallel, forEach = fire-and-forget disaster.
Q37: for...of with await — Each Iteration Is a Separate Microtask Checkpoint
javascriptasync function test() {
const items = [1, 2, 3];
for (const item of items) {
await Promise.resolve();
console.log('item', item);
}
}
test();
Promise.resolve()
.then(() => console.log('X'))
.then(() => console.log('Y'))
.then(() => console.log('Z'));Output: X item 1 Y item 2 Z item 3
Why: Each await in the for loop yields control. The loop resumes as a microtask, interleaving with the chained .then() calls. They take turns on the microtask queue.
Q38: Promise.race — Losers Still Execute
javascriptlet sideEffect = 0;
const slow = new Promise(resolve => {
setTimeout(() => {
sideEffect++;
console.log('slow finished');
resolve('slow');
}, 200);
});
const fast = new Promise(resolve => {
setTimeout(() => {
resolve('fast');
}, 50);
});
const winner = await Promise.race([slow, fast]);
console.log('winner:', winner);
console.log('sideEffect:', sideEffect); // 0 at this point
await new Promise(r => setTimeout(r, 300));
console.log('sideEffect after wait:', sideEffect); // 1!Output:
winner: fast
sideEffect: 0
slow finished
sideEffect after wait: 1Why: Promise.race does NOT cancel losers. The slow promise's callback still fires. This is a common source of bugs — resource leaks, double writes, stale state updates.
Fix: Use AbortController to actually cancel the losing operations.
Q39: Promise.all with setTimeout Mixing — Parallel Timing
javascriptconst makeTimer = (label, ms) =>
new Promise(resolve => setTimeout(() => {
console.log(label);
resolve(label);
}, ms));
console.log('start');
await Promise.all([
makeTimer('A', 300),
makeTimer('B', 100),
makeTimer('C', 200),
]);
console.log('all done');Output:
start
B ← 100ms
C ← 200ms
A ← 300ms
all done ← immediately after A (last one)Why: Promise.all runs all promises concurrently. They resolve in time-order, not array-order. But the resolved value array still preserves the original order: ['A', 'B', 'C'].
Q40: MutationObserver Uses Microtasks
javascript// Browser only
const div = document.createElement('div');
const observer = new MutationObserver(() => {
console.log('mutation');
});
observer.observe(div, { attributes: true });
console.log('before');
div.setAttribute('data-x', '1');
console.log('after');
Promise.resolve().then(() => console.log('promise'));
queueMicrotask(() => console.log('qMT'));Output:
before
after
mutation
promise
qMTWhy: MutationObserver callbacks are microtasks. The mutation is observed synchronously but the callback is queued as a microtask. Since it was queued before the Promise and queueMicrotask, it runs first (FIFO within the microtask queue).
Q41: MutationObserver Batches Synchronous Mutations
javascript// Browser only
const div = document.createElement('div');
let callCount = 0;
const observer = new MutationObserver((mutations) => {
callCount++;
console.log('mutations:', mutations.length, 'callCount:', callCount);
});
observer.observe(div, { attributes: true });
div.setAttribute('a', '1');
div.setAttribute('b', '2');
div.setAttribute('c', '3');Output: mutations: 3 callCount: 1
Why: MutationObserver batches all synchronous DOM changes into a single microtask callback. Even though we made 3 changes, the observer fires once with all 3 mutations. This is more efficient than individual mutation events.
Q42: setImmediate vs setTimeout(0) — The Definitive Test
javascript// Node.js only
const results = [];
for (let i = 0; i < 100; i++) {
const order = [];
setTimeout(() => order.push('T'), 0);
setImmediate(() => {
order.push('I');
if (order.length === 2) results.push(order.join(''));
});
}
setTimeout(() => {
const TI = results.filter(r => r === 'TI').length;
const IT = results.filter(r => r === 'IT').length;
console.log(`TI: ${TI}, IT: ${IT}`);
// Mix of both! Neither order is guaranteed.
}, 1000);Why: At the top level (not inside I/O), setTimeout(0) and setImmediate race. The timer resolution varies by OS. Inside I/O callbacks, setImmediate always wins because we're in the poll phase and check phase comes next.
Q43: setImmediate Recursion Does NOT Starve I/O
javascript// Node.js only
let count = 0;
function recurseImmediate() {
count++;
if (count <= 5) setImmediate(recurseImmediate);
}
setImmediate(recurseImmediate);
setTimeout(() => console.log('timeout ran, count:', count), 0);Output: timeout ran, count: 1 (or 2, or small number)
Why: Unlike process.nextTick, recursive setImmediate does NOT starve the event loop. Each setImmediate callback runs in the check phase, but the recursive one is scheduled for the NEXT iteration of the event loop, not the current one. So timers and I/O still get a chance to run between iterations.
This is why setImmediate is preferred over process.nextTick for recursive patterns.
Q44: AbortController with Fetch — Timing Matters
javascriptconst controller = new AbortController();
// Abort before the fetch even starts
controller.abort();
try {
const response = await fetch('https://example.com', {
signal: controller.signal,
});
console.log('success'); // Never reached
} catch (err) {
console.log(err.name); // 'AbortError'
console.log(controller.signal.aborted); // true
console.log(controller.signal.reason); // DOMException or Error
}Output: AbortError then true then the abort reason
Why: Aborting BEFORE calling fetch still throws. The signal is checked immediately when the fetch starts. This is useful for pre-cancellation patterns (e.g., a component unmounts before the request fires).
Q45: AbortController with Custom Async Operations
javascriptasync function longTask(signal) {
for (let i = 0; i < 5; i++) {
if (signal.aborted) {
throw new Error('Aborted at step ' + i);
}
console.log('step', i);
await new Promise(r => setTimeout(r, 100));
}
return 'done';
}
const controller = new AbortController();
setTimeout(() => controller.abort(), 250);
try {
const result = await longTask(controller.signal);
console.log(result);
} catch (e) {
console.log('caught:', e.message);
}Output:
step 0
step 1
step 2
caught: Aborted at step 3Why: The abort signal is checked at the top of each iteration. Steps 0, 1, 2 run fine (under 250ms). By step 3 (~300ms), the abort has fired. The check at the start of iteration 3 throws.
Gotcha: If you don't check signal.aborted, the operation runs to completion even after abort. AbortController doesn't magically cancel running code — you must cooperatively check it.
Q46: Event Listeners Fire Synchronously, Creating Microtask Traps
javascriptconst btn = new EventTarget();
btn.addEventListener('click', () => {
console.log('listener 1');
Promise.resolve().then(() => console.log('microtask 1'));
});
btn.addEventListener('click', () => {
console.log('listener 2');
Promise.resolve().then(() => console.log('microtask 2'));
});
btn.dispatchEvent(new Event('click'));
console.log('after dispatch');Output:
listener 1
listener 2
after dispatch
microtask 1
microtask 2Why: dispatchEvent is synchronous — all listeners run as part of the current call stack. Microtasks queued inside listeners don't drain between listeners, they drain after the entire synchronous dispatch completes.
BUT in browsers with real user clicks:
javascript// If user physically clicks the button:
// listener 1
// microtask 1 ← microtask drains between listeners!
// listener 2
// microtask 2This is a real difference between dispatchEvent() and actual user interaction. With dispatchEvent, all listeners share one synchronous call stack. With real clicks, each listener is essentially its own task.
Q47: Event Listener removal During Dispatch
javascriptconst target = new EventTarget();
function handler1() {
console.log('handler1');
target.removeEventListener('test', handler2);
}
function handler2() {
console.log('handler2');
}
target.addEventListener('test', handler1);
target.addEventListener('test', handler2);
target.dispatchEvent(new Event('test'));Output: handler1 handler2
Why: Even though handler2 is removed during dispatch, it still fires for the current dispatch cycle. The browser snapshots the listener list when dispatch begins. Removal takes effect on the NEXT dispatch.
Q48: Top-Level Await Blocks Module Graph
javascript// slow-module.mjs
console.log('slow: start');
await new Promise(r => setTimeout(r, 2000));
console.log('slow: done');
export const value = 42;
// fast-module.mjs
console.log('fast: loaded');
export const speed = 'fast';
// main.mjs
import { value } from './slow-module.mjs';
import { speed } from './fast-module.mjs';
console.log('main:', value, speed);Output:
slow: start
(2 second pause)
slow: done
fast: loaded
main: 42 fastWhy: Top-level await blocks all modules that depend on it AND all sibling imports. fast-module.mjs won't even start loading until slow-module.mjs finishes, because modules in the same graph are evaluated in dependency order.
Gotcha: This can silently make your app startup horrifically slow if a deeply nested dependency uses top-level await.
Q49: Top-Level Await — Parallel Import Trick
javascript// main.mjs — WRONG (sequential):
import { a } from './slow-a.mjs'; // top-level await inside, 2s
import { b } from './slow-b.mjs'; // top-level await inside, 2s
// Total: ~4 seconds
// main.mjs — STILL sequential!
// Static imports CANNOT be parallelized with top-level await
// main.mjs — RIGHT (parallel with dynamic import):
const [modA, modB] = await Promise.all([
import('./slow-a.mjs'),
import('./slow-b.mjs'),
]);
// Total: ~2 seconds (parallel)Lesson: Static import declarations execute in dependency order. To parallelize modules that use top-level await, you must use dynamic import() with Promise.all.
Q50: Generator Yield Interleaves with Event Loop
javascriptfunction* gen() {
console.log('gen: before yield 1');
yield 1;
console.log('gen: before yield 2');
yield 2;
}
const it = gen();
console.log('A');
console.log('next:', it.next());
console.log('B');
setTimeout(() => {
console.log('next:', it.next());
console.log('next:', it.next());
}, 0);
console.log('C');Output:
A
gen: before yield 1
next: { value: 1, done: false }
B
C
gen: before yield 2
next: { value: 2, done: false }
next: { value: undefined, done: true }Why: Generators are lazy and synchronous. Each .next() runs the generator until the next yield, then suspends. The generator doesn't resume until someone explicitly calls .next(). When .next() is called inside setTimeout, the generator resumes in that macrotask context.
Q51: Async Generator + for await + Break = Cleanup
javascriptasync function* infinite() {
let i = 0;
try {
while (true) {
yield i++;
await new Promise(r => setTimeout(r, 50));
}
} finally {
console.log('generator cleanup, last i:', i);
}
}
async function consume() {
for await (const val of infinite()) {
console.log('val:', val);
if (val >= 2) break;
}
console.log('after loop');
}
await consume();Output:
val: 0
val: 1
val: 2
generator cleanup, last i: 3
after loopWhy: When you break out of a for await...of, the engine calls .return() on the async generator, which triggers the finally block. This is how you do proper cleanup (closing connections, releasing resources). Without try/finally, the generator just gets garbage collected silently.
Q52: Promise.resolve(thenable) — The Extra Tick Trap Revisited
javascriptconst thenable = {
then(resolve) {
console.log('thenable.then called');
resolve('thenable value');
}
};
Promise.resolve(thenable).then(v => console.log('resolved:', v));
Promise.resolve('plain').then(v => console.log('plain:', v));Output:
thenable.then called
plain: plain
resolved: thenable valueWhy: Promise.resolve(thenable) wraps the thenable by calling its .then() synchronously, but the resolution is still async (takes a microtask tick). Meanwhile, Promise.resolve('plain') already has its value, so its .then() callback runs first.
This is why duck-typed thenables can cause subtle ordering bugs when mixed with real Promises.
Q53: Mixing async/await with Promise.all and Error Handling
javascriptasync function failing() {
await new Promise(r => setTimeout(r, 50));
throw new Error('fail');
}
async function succeeding() {
await new Promise(r => setTimeout(r, 100));
console.log('succeeding done');
return 'ok';
}
try {
const [a, b] = await Promise.all([failing(), succeeding()]);
} catch (e) {
console.log('caught:', e.message);
}
await new Promise(r => setTimeout(r, 200));
console.log('final');Output:
caught: fail
succeeding done
finalWhy: Promise.all rejects immediately when failing() rejects at 50ms, but succeeding() is still running — its setTimeout callback fires at 100ms and logs regardless. Promise.all doesn't cancel ongoing operations.
This causes bugs: If succeeding() writes to a database, that write still happens even though you're in the catch block. Use AbortController to truly cancel.
Q54: Double await — What Does It Mean?
javascriptasync function test() {
const result = await await Promise.resolve(
Promise.resolve('deep')
);
console.log(result);
}
test();
console.log('sync');Output: sync deep
Why: Promise.resolve(Promise.resolve('deep')) doesn't double-wrap — Promise.resolve returns the same promise if passed a native promise. So await await samePromise is just two microtask ticks to unwrap the same value. The first await gets the inner promise, the second await gets 'deep'.
Q55: Promise Constructor + Immediate Reject + Then
javascriptconst p = new Promise((resolve, reject) => {
reject('error');
resolve('success'); // Does this do anything?
});
p.then(
v => console.log('fulfilled:', v),
e => console.log('rejected:', e)
);Output: rejected: error
Why: A promise can only settle once. After reject('error'), calling resolve('success') is silently ignored. First settlement wins. This is by spec and is a safety feature.
Gotcha variation:
javascriptconst p = new Promise((resolve) => {
resolve('first');
resolve('second'); // Ignored
});
p.then(v => console.log(v)); // 'first'Q56: Microtask Ordering with Multiple Await Chains
javascriptasync function a() {
await Promise.resolve();
console.log('a1');
await Promise.resolve();
console.log('a2');
}
async function b() {
await Promise.resolve();
console.log('b1');
await Promise.resolve();
console.log('b2');
}
a();
b();
console.log('sync');Output: sync a1 b1 a2 b2
Why: Both functions hit their first await and suspend. After sync code finishes:
- Microtask tick 1: both
aandbresume →a1(queued first),b1 - Each hits second
awaitand suspends again - Microtask tick 2: both resume →
a2,b2
Key insight: Multiple async functions interleave at await points. They don't run sequentially — they take turns on the microtask queue, creating a round-robin effect.
Q57: AbortSignal.timeout — The Modern Pattern
javascript// Node.js 18+ / Modern browsers
async function fetchWithTimeout() {
try {
const response = await fetch('https://httpbin.org/delay/10', {
signal: AbortSignal.timeout(2000),
});
console.log('response:', response.status);
} catch (err) {
console.log(err.name); // 'TimeoutError' (NOT AbortError!)
console.log(err instanceof DOMException); // true
}
}Output: TimeoutError then true
Why: AbortSignal.timeout() throws a TimeoutError, not an AbortError. This lets you distinguish between user-initiated abort and timeout. Before this API, you had to manually wire setTimeout + AbortController, which was error-prone.
javascript// Combining user abort with timeout:
const userController = new AbortController();
const signal = AbortSignal.any([
userController.signal,
AbortSignal.timeout(5000),
]);
// AbortSignal.any() — first signal to abort winsQ58: process.nextTick Inside setImmediate — Priority Reasserts
javascript// Node.js only
setImmediate(() => {
console.log('immediate 1');
process.nextTick(() => console.log('nextTick inside immediate'));
Promise.resolve().then(() => console.log('promise inside immediate'));
console.log('immediate 1 end');
});
setImmediate(() => {
console.log('immediate 2');
});Output:
immediate 1
immediate 1 end
nextTick inside immediate
promise inside immediate
immediate 2Why: Between each setImmediate callback, Node.js drains the nextTick and promise microtask queues. So nextTick and promise callbacks queued during immediate 1 run before immediate 2.
This is the same pattern as setTimeout callbacks — microtasks always drain between macrotask callbacks.
Q59: Generator + Promise — Building Async/Await from Scratch
javascriptfunction run(generatorFn) {
const it = generatorFn();
function step(value) {
const result = it.next(value);
if (result.done) return Promise.resolve(result.value);
return Promise.resolve(result.value).then(step);
}
return step();
}
run(function* () {
console.log('start');
const a = yield Promise.resolve(1);
console.log('a:', a);
const b = yield Promise.resolve(a + 1);
console.log('b:', b);
return b + 1;
}).then(result => console.log('result:', result));
console.log('sync');Output:
start
sync
a: 1
b: 2
result: 3Why: This is how async/await works under the hood. The run function drives the generator, passing each yielded promise's value back in via .next(value). start logs synchronously, then the generator yields a promise and suspends. sync runs. Then microtasks drive the generator forward.
This is exactly what Babel used to do to transpile async/await for older runtimes.
Q60: Event Loop Phases — I/O Callback Queues Microtask
javascript// Node.js only
const fs = require('fs');
fs.readFile(__filename, () => {
console.log('I/O callback');
process.nextTick(() => {
console.log('nextTick in I/O');
Promise.resolve().then(() => console.log('promise in nextTick in I/O'));
});
setImmediate(() => console.log('immediate in I/O'));
Promise.resolve().then(() => {
console.log('promise in I/O');
process.nextTick(() => console.log('nextTick in promise in I/O'));
});
});Output:
I/O callback
nextTick in I/O
promise in nextTick in I/O
promise in I/O
nextTick in promise in I/O
immediate in I/OWhy: After the I/O callback:
- nextTick queue drains →
nextTick in I/O, which queues a promise - Promise queue drains →
promise in nextTick in I/O, thenpromise in I/O, which queues a nextTick - nextTick queue drains again →
nextTick in promise in I/O - Both microtask queues empty → move to check phase →
immediate in I/O
The nextTick and promise queues ping-pong until both are empty before any macrotask runs.
Q61: Async Stack Trace Gotcha — Where Did the Error Come From?
javascriptasync function inner() {
await new Promise(r => setTimeout(r, 100));
throw new Error('deep error');
}
async function middle() {
return inner(); // Note: no await!
}
async function outer() {
try {
await middle();
} catch (e) {
console.log(e.stack);
}
}
outer();Output: Stack trace shows inner and outer but may NOT show middle.
Why: middle() does return inner() without await. This means middle is not on the async call stack when the error occurs — it returned the promise directly without suspending. The engine can't reconstruct the frame.
Fix:
javascriptasync function middle() {
return await inner(); // 'await' keeps middle in the stack trace
}Lesson: return await is NOT redundant in error-handling scenarios. It preserves the stack frame for better debugging. ESLint's no-return-await rule is controversial for this reason.
Q62: Promise.race with Immediately Resolved Promise
javascriptconst never = new Promise(() => {}); // never settles
const result = await Promise.race([
never,
Promise.resolve('instant'),
]);
console.log(result);Output: instant
Why: Promise.race resolves with the first promise to settle. Promise.resolve('instant') is already resolved, so race settles immediately. The never promise doesn't matter.
But watch out:
javascriptconst result = await Promise.race([
new Promise(() => {}), // never settles
]);
// This hangs forever! Race with a single never-settling promise = deadlockQ63: Thenable in Promise.all — Non-Promise Objects
javascriptconst thenable = {
then(resolve) {
setTimeout(() => resolve('thenable!'), 100);
}
};
const results = await Promise.all([
Promise.resolve('promise'),
thenable,
42, // plain value
'string', // plain value
]);
console.log(results);Output: ['promise', 'thenable!', 42, 'string']
Why: Promise.all wraps each element with Promise.resolve(). Plain values become immediately resolved promises. Thenables get their .then() called. You can mix promises, thenables, and plain values freely.
Gotcha: An object with a then property that's a function is treated as a thenable, even accidentally:
javascriptconst data = { then: 'some string value', other: 42 };
// Promise.resolve(data) — works fine, 'then' is not a function
const bug = { then: () => {} }; // 'then' IS a function!
// Promise.resolve(bug) — NEVER settles! The 'then' function
// is called but never calls resolve/reject.Q64: for await...of on Non-Async Iterable — It Works!
javascriptconst syncIterable = {
[Symbol.iterator]() {
let i = 0;
return {
next() {
return i < 3
? { value: Promise.resolve(i++), done: false }
: { done: true };
}
};
}
};
for await (const val of syncIterable) {
console.log(val);
}Output: 0 1 2
Why: for await...of works on regular iterables too, not just async iterables. It awaits each value. If the values are promises, they get unwrapped. If they're plain values, they pass through like await plainValue.
This is a feature, not a bug — it makes for await a universal loop for any mix of sync/async iterables.
Q65: Promise.any — All Reject = AggregateError
javascriptasync function test() {
try {
await Promise.any([
Promise.reject('error 1'),
Promise.reject('error 2'),
Promise.reject('error 3'),
]);
} catch (e) {
console.log(e instanceof AggregateError); // true
console.log(e.errors); // ['error 1', 'error 2', 'error 3']
console.log(e.message); // 'All promises were rejected'
}
}
test();Output:
true
['error 1', 'error 2', 'error 3']
All promises were rejectedWhy: Promise.any only rejects when ALL promises reject. The AggregateError collects every rejection reason. This is the inverse of Promise.all — one must succeed instead of all must succeed.
Gotcha: AggregateError.errors preserves the original array order, NOT the rejection order.
Q66: Mixing sync throw with async in Promise.all
javascriptfunction syncThrow() {
throw new Error('sync boom');
}
async function asyncOk() {
await new Promise(r => setTimeout(r, 100));
console.log('async completed');
return 'ok';
}
try {
await Promise.all([syncThrow(), asyncOk()]);
} catch (e) {
console.log('caught:', e.message);
}
await new Promise(r => setTimeout(r, 200));
console.log('end');Output:
caught: sync boom
async completed
endWait — trick question! This actually throws BEFORE Promise.all even executes. syncThrow() is evaluated as an argument, and it throws synchronously. The try/catch catches it.
But asyncOk() was also called as an argument — arguments evaluate left to right. Since syncThrow() throws first, asyncOk() is NEVER called... right?
Actually: JavaScript evaluates all function call arguments before passing them. Arguments are evaluated left-to-right. syncThrow() is called first and throws, so asyncOk() is never called. The output is just:
caught: sync boom
endasync completed never appears because asyncOk() was never invoked.
Q67: setTimeout Minimum Delay is NOT 0
javascript// Node.js
console.time('timer');
setTimeout(() => {
console.timeEnd('timer');
}, 0);Output: timer: 1.xxx ms (NOT 0ms)
Why: In Node.js, setTimeout(fn, 0) is silently coerced to setTimeout(fn, 1). The minimum delay is 1ms. In browsers, after 4 nested setTimeout calls, the minimum is clamped to 4ms (per HTML spec).
javascript// Prove it in Node.js:
setTimeout(() => {
setTimeout(() => {
setTimeout(() => {
setTimeout(() => {
console.log('depth 4');
}, 0);
}, 0);
}, 0);
}, 0);
// Each level adds ~1ms in Node, ~1-4ms in browsersQ68: Unhandled Rejection Window — You Have One Microtask
javascriptconst p = Promise.reject('oops');
// Is this "handled" or "unhandled"?
setTimeout(() => {
p.catch(e => console.log('late catch:', e));
}, 0);Behavior: This triggers an unhandledRejection event in Node.js! Even though we add a .catch() handler, we add it too late — in the next macrotask. The rejection is considered unhandled if no handler is attached by the end of the current microtask checkpoint.
This IS handled:
javascriptconst p = Promise.reject('oops');
// Attaching synchronously or in a microtask — fine:
queueMicrotask(() => {
p.catch(e => console.log('caught:', e)); // Handled!
});Lesson: Always attach .catch() synchronously or in the same microtask tick. Never rely on setTimeout to add error handlers.