Event-loop explainer

Why does src/js-q.js print A H D F G B C E — a step-by-step trace through the JavaScript runtime. Part 2 → · Part 3 →

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:

A·H·D·F·G·B·C·E

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.

Quick map:
  • A, H — synchronous console.log calls run in source order.
  • D, F, G — three microtasks: M1, M2, and M4 (queued by M2 during the drain).
  • B — the first setTimeout callback (T1) fires.
  • C — microtask M3 (queued inside T1) drains before any other timer runs.
  • E — the second setTimeout callback (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
The golden rule: after the stack empties (or after every macrotask completes), the runtime drains the microtask queue completely — including any microtasks that get queued while draining — before picking up the next macrotask.

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.

Call stackrunning…
Microtask Qempty
Macrotask Qempty
Output

Step-by-step

  1. [1] console.log('A')logs A.
  2. [2] setTimeout(…, 0) registers timer T1. Note: T1's body is not executed yet — it's stashed in the macrotask queue.
  3. [3] Promise.resolve().then(…) queues microtask M1 immediately (the promise is already resolved).
  4. [4] Another Promise.resolve().then(…) queues microtask M2.
  5. [5] console.log('H')logs H.
Call stackempty
Microtask Q[M1, M2]
Macrotask Q[T1]
OutputA 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.

Call stackempty
Microtask Qempty
Macrotask Q[T1, T2]
OutputA H D F G

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
Call stackempty
Microtask Qempty
Macrotask Q[T2]
OutputA H D F G B 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.

Call stackempty
Microtask Qempty
Macrotask Qempty
OutputA H D F G B C E

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
1sync[1] log 'A'[A][][]A
2sync[2] schedule T1 +T1[setTimeout][][T1]A
3sync[3] queue M1 +M1[then][M1][T1]A
4sync[4] queue M2 +M2[then][M1, M2][T1]A
5sync[5] log 'H'[H][M1, M2][T1]A H
— stack empty → drain microtasks —
6microM1: log 'D', schedule T2 +T2[M1][M2][T1, T2]A H D
7microM2: log 'F', queue M4 +M4[M2][M4][T1, T2]A H D F
8microM4: log 'G'[M4][][T1, T2]A H D F G
— microtasks drained → take next macrotask —
9macroT1: log 'B', queue M3 +M3[T1][M3][T2]A H D F G B
— after every macrotask → drain microtasks —
10microM3: log 'C'[M3][][T2]A H D F G B C
— microtasks drained → take next macrotask —
11macroT2: 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 setTimeout callback (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.

Practical hazard: A chain of microtasks that keeps queueing more microtasks can starve the macrotask queue forever — timers never fire, I/O callbacks never run. This is called microtask starvation. Avoid 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
Generalisation: if you queue a Promise.then inside a setTimeout callback, the .then always fires immediately after that callback returns — never after a competing timer that's already in the queue.

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.

Subtle point: 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.log not 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.
  • "await blocks 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.nextTick is its own queue, drained before the microtask queue. So process.nextTick(...) beats Promise.resolve().then(...).
  • setImmediate(...) runs in the check phase, which comes after poll. In practice, setImmediate usually fires before a setTimeout(..., 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

Try this yourself: Paste 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.