▸ The code (annotated)
I've added step markers [1]–[5] for the synchronous-phase events and labels T1/T2 (timers) and M1–M4 (microtasks) so we can reference them later.
// What order will the letters be printed in? console.log('A'); // [1] sync setTimeout(() => { // [2] schedule T1 console.log('B'); Promise.resolve().then(() => { // inside T1 → queue M3 console.log('C'); }); }, 0); Promise.resolve().then(() => { // [3] queue M1 console.log('D'); setTimeout(() => { // inside M1 → schedule T2 console.log('E'); }, 0); }) Promise.resolve().then(() => { // [4] queue M2 console.log('F'); Promise.resolve().then(() => { // inside M2 → queue M4 console.log('G'); }); }) console.log('H') // [5] sync
▸ The answer
The output, in order:
One-sentence summary: all synchronous logs first, then drain every microtask (including any queued mid-drain), then process timer callbacks one at a time — each timer's microtasks drain between timer invocations.
- A, H — synchronous
console.logcalls run in source order. - D, F, G — three microtasks: M1, M2, and M4 (queued by M2 during the drain).
- B — the first
setTimeoutcallback (T1) fires. - C — microtask M3 (queued inside T1) drains before any other timer runs.
- E — the second
setTimeoutcallback (T2) fires last; it was scheduled later than T1.
▸ Mental model
JavaScript is single-threaded with three buckets that the runtime cycles between:
┌────────────────────────────────────────┐ │ CALL STACK │ ← sync code (LIFO) │ console.log, setTimeout(…), … │ └────────────────┬───────────────────────┘ │ stack empty? ▼ ┌────────────────────────────────────────┐ │ MICROTASK QUEUE (drain ALL, FIFO) │ │ Promise .then/.catch/.finally │ │ queueMicrotask, MutationObserver │ └────────────────┬───────────────────────┘ │ empty? ▼ ┌────────────────────────────────────────┐ │ MACROTASK QUEUE (take ONE, FIFO) │ │ setTimeout, setInterval, I/O, │ │ setImmediate, message events, … │ └────────────────┬───────────────────────┘ │ └─→ back to MICROTASK drain
Three queue shapes in this program
- Call stack — runs
[1]through[5]top-to-bottom synchronously. - Microtask queue — collects M1, M2 during sync; M4 gets added during drain; M3 added later from inside T1.
- Macrotask queue (Timers) — collects T1 during sync, T2 during the microtask drain.
▸ Phase 1 Synchronous execution
The runtime starts at the top of the file and runs every statement on the call stack until it returns. Scheduling a callback (setTimeout, .then) is itself synchronous — only the body of the callback is deferred.
Step-by-step
- [1]
console.log('A')→ logs A. - [2]
setTimeout(…, 0)registers timer T1. Note: T1's body is not executed yet — it's stashed in the macrotask queue. - [3]
Promise.resolve().then(…)queues microtask M1 immediately (the promise is already resolved). - [4] Another
Promise.resolve().then(…)queues microtask M2. - [5]
console.log('H')→ logs H.
▸ Phase 2 Drain the microtask queue
The synchronous phase ended with the stack empty. Before touching any timer, the runtime drains every microtask — and any microtasks those microtasks queue, and so on, until the queue is genuinely empty.
M1 runs
console.log('D'); // logs D setTimeout(() => { ... }, 0); // schedules T2 → macroQ: [T1, T2]
After M1: output = "A H D", microQ [M2], macroQ [T1, T2].
M2 runs
console.log('F'); // logs F Promise.resolve().then(...); // queues M4 (still during drain!)
After M2: output = "A H D F", microQ [M4] — the drain isn't done yet because M4 was added before the queue went empty.
M4 runs
console.log('G'); // logs G
After M4: output = "A H D F G", microQ []. Drain complete.
▸ Phase 3 First timer — T1
Microtask queue is now empty. The runtime pulls the next macrotask: T1 (the first setTimeout, scheduled in step [2] during the sync phase).
T1 body runs
console.log('B'); // logs B Promise.resolve().then(...); // queues M3
After T1: output = "A H D F G B", microQ [M3], macroQ [T2].
Drain microtasks again
After every macrotask completes, the microtask queue is drained — that's the golden rule. So M3 fires immediately, before T2 gets a chance.
console.log('C'); // logs C
▸ Phase 4 Second timer — T2
The runtime pulls the next macrotask: T2, which was scheduled during M1 in Phase 2. Its body simply logs E.
console.log('E'); // logs E
No microtasks were queued during T2, so the drain on exit is a no-op. The macrotask queue is empty. Program ends.
▸ Full execution trace
Every state transition from start to finish. Each row is one bite of work — running a frame on the call stack, a microtask, or a macrotask. +x = queued, -x = dequeued.
| # | Phase | Action | Call stack | Microtask Q | Macrotask Q | Output so far |
|---|---|---|---|---|---|---|
| 1 | sync | [1] log 'A' | [A] | [] | [] | A |
| 2 | sync | [2] schedule T1 +T1 | [setTimeout] | [] | [T1] | A |
| 3 | sync | [3] queue M1 +M1 | [then] | [M1] | [T1] | A |
| 4 | sync | [4] queue M2 +M2 | [then] | [M1, M2] | [T1] | A |
| 5 | sync | [5] log 'H' | [H] | [M1, M2] | [T1] | A H |
| — stack empty → drain microtasks — | ||||||
| 6 | micro | M1: log 'D', schedule T2 +T2 | [M1] | [M2] | [T1, T2] | A H D |
| 7 | micro | M2: log 'F', queue M4 +M4 | [M2] | [M4] | [T1, T2] | A H D F |
| 8 | micro | M4: log 'G' | [M4] | [] | [T1, T2] | A H D F G |
| — microtasks drained → take next macrotask — | ||||||
| 9 | macro | T1: log 'B', queue M3 +M3 | [T1] | [M3] | [T2] | A H D F G B |
| — after every macrotask → drain microtasks — | ||||||
| 10 | micro | M3: log 'C' | [M3] | [] | [T2] | A H D F G B C |
| — microtasks drained → take next macrotask — | ||||||
| 11 | macro | T2: log 'E' | [T2] | [] | [] | A H D F G B C E |
| — queues all empty → program exits — | ||||||
▸ Why does G print before B?
This is the trickiest line of the trace. Both G and B come from scheduled callbacks — but one is a microtask and the other is a macrotask.
- G is queued by M2, while the microtask queue is being drained.
- B is the first
setTimeoutcallback (T1), waiting in the macrotask queue.
The rule: the microtask drain doesn't stop until the queue is empty at the moment of inspection. M2 adds M4 to a queue that's currently being processed, so M4 (G) becomes part of the same drain. Only after M4 finishes and the queue is genuinely empty does the runtime advance to the macrotask queue.
while (true) queueMicrotask(...) patterns.▸ Why is C sandwiched between B and E?
C is microtask M3, queued inside T1. By the time it's queued, the drain that processed D/F/G has already finished — so M3 wasn't part of that drain.
But here's the second half of the rule: microtasks drain after every macrotask, not just after the synchronous phase. So as soon as T1 returns control to the runtime, the loop checks the microtask queue and finds M3 waiting. M3 fires before T2 gets to run, even though both were waiting at that point.
T1 starts → log B → queue M3 → T1 returns
↓
drain microtasks → M3 fires → log C
↓
T2 starts → log E
▸ Why does E come last?
E is T2's body. T2 was scheduled during Phase 2 (inside M1) — so its 0 ms timer started ticking after T1's. By the time the runtime is ready to dequeue timers, both T1 and T2 have elapsed, but they fire in the order they were registered.
Timers with the same delay form a FIFO queue. T1 was registered first (synchronously in the sync phase), T2 second (inside a microtask). So T1 fires first, then T2.
setTimeout(fn, 0) doesn't mean "next tick" — it means "after the call stack clears, after all microtasks drain, after any earlier-scheduled timers have fired, and after the minimum delay (typically 1 ms in Node, 4 ms for nested timers in browsers)". It's at the back of a long line.▸ Gotchas & takeaways
Things to internalise
- Synchronous code always wins. Every
console.lognot behind a setTimeout/Promise fires before any deferred work. - Microtasks always beat macrotasks — even if a microtask is queued after a macrotask. If both are pending at the same moment, the microtask runs first.
- Microtasks drain greedily. Any microtask queued during the drain joins the same drain. The macrotask queue waits.
- Macrotasks process one at a time. After each one, the runtime drains microtasks again before dequeuing the next macrotask.
Common misconceptions
- ✗ "
setTimeout(fn, 0)runs immediately." → It runs at the back of the next macrotask queue. - ✗ "Promise callbacks run asynchronously, just like setTimeout." → They're both async, but Promise callbacks always run before setTimeout callbacks when both are ready.
- ✗ "Microtasks fire as soon as queued." → They fire when the runtime drains the queue — typically after the current task or function returns.
- ✗ "
awaitblocks the thread." → It returns control to the runtime; the continuation is queued as a microtask.
Node-specific notes
- Node's event loop has multiple phases: timers, pending callbacks, idle/prepare, poll, check, close. Macrotasks are split across these phases.
process.nextTickis its own queue, drained before the microtask queue. Soprocess.nextTick(...)beatsPromise.resolve().then(...).setImmediate(...)runs in the check phase, which comes after poll. In practice, setImmediate usually fires before asetTimeout(..., 0)when both are queued outside an I/O callback — but not always.- This file's behaviour is identical in browsers and Node, because it uses only setTimeout and Promise — both have spec'd ordering.
▸ Further reading
- Continue the series: Part 2 — Promise executor & chain interleave · Part 3 — await migration
- HTML Spec — Event loops (canonical definition for browsers)
- Node.js — The Node.js event loop, timers, and process.nextTick
- MDN — The event loop
- Jake Archibald — Tasks, microtasks, queues and schedules (still the definitive deep-dive)
- Jake Archibald — "In the loop" (JSConf.Asia 2018) (45-min talk, same author)
src/js-q.js into jsv9000.app or latentflip's Loupe for an animated visualisation of the call stack + queues. Step through and watch the queue states match the trace table above.