Event-loop explainer #2

Why does src/js-q-2.js print 1 2 3 5 4 A1 B1 A2 B2 A3 B3 — two more puzzles to break the most common wrong mental models. ← Part 1 · Part 3 →

The code

Two short puzzles in one file. We'll trace each separately, then show the combined output.

// Two more puzzles. What order will these print in?

// ── Puzzle 1: the Promise executor body
console.log('1');
new Promise((resolve) => {
  console.log('2');
  resolve();
  console.log('3');
}).then(() => console.log('4'));
console.log('5');


// ── Puzzle 2: two parallel .then chains
Promise.resolve().then(() => console.log('A1'))
                 .then(() => console.log('A2'))
                 .then(() => console.log('A3'));

Promise.resolve().then(() => console.log('B1'))
                 .then(() => console.log('B2'))
                 .then(() => console.log('B3'));

The answer

Verified by running node src/js-q-2.js:

1·2·3·5·4·A1·B1·A2·B2·A3·B3
Two surprises baked into one output:
  • The '3' log inside the Promise executor body fires before '5' on the main thread — the executor is synchronous.
  • The A and B chains alternate (A1 B1 A2 B2 A3 B3) instead of completing one chain before starting the other.

Puzzle 1 — the claim

Most people who see this code for the first time predict:

Common guess (wrong)

1 5 2 3 4

"The Promise is async, so its body runs later."

Actual output

1 2 3 5 4

The executor runs immediately, on the call stack.

The mental model trap: people read new Promise(...) and assume the runtime "puts it off until later". It doesn't. Only the .then callback is deferred.

Puzzle 1 — why the executor is synchronous

The Promise constructor takes a function (the executor) and calls it right then and there, on the current stack frame, before the constructor returns. This is necessary by design: the executor's job is to start the work that will eventually fulfil or reject the promise — DB queries, fetch requests, event listener wiring. If the executor were deferred, you'd lose synchronous access to things like the resolve function for tricky patterns, and the promise would always start in a half-constructed state.

What's deferred is not the executor but anything you attach via .then, .catch, .finally. Those go on the microtask queue.

Reading the executor carefully

new Promise((resolve) => {
  console.log('2');   // sync — logs 2 immediately
  resolve();          // flips state to fulfilled, but does NOT exit the function
  console.log('3');   // sync — still logs, after resolve()
})
Bonus surprise: calling resolve() doesn't stop execution. It marks the promise as fulfilled but the executor keeps running to its closing }. Code after resolve() still runs synchronously.

And then the .then

The .then(() => console.log('4')) is called on a already-fulfilled promise, so its callback is queued as a microtask right away. But like any microtask, it waits until the current stack is empty — meaning '5' logs first.

Puzzle 1 — trace

# Phase Action Microtask Q Output
1synclog '1'[]1
2syncenter executor → log '2'[]1 2
3syncresolve() — promise marked fulfilled[]1 2
4synclog '3' (still in executor!)[]1 2 3
5sync.then(cb_4) on fulfilled promise → queue M4[M4]1 2 3
6synclog '5'[M4]1 2 3 5
— stack empty → drain microtasks —
7microM4: log '4'[]1 2 3 5 4

Puzzle 2 — the claim

Common guess (wrong)

A1 A2 A3 B1 B2 B3

"The A chain is queued first, so it drains first."

Actual output

A1 B1 A2 B2 A3 B3

The chains interleave, one step at a time.

This one breaks the intuition that a chain is "a thing" — a single deferred sequence. It isn't. A chain is just a series of independent microtask queueings, each triggered when the previous step settles.

Puzzle 2 — why chains interleave

Look at what actually happens when you write .then(...).then(...).then(...):

p0 = Promise.resolve()          // already fulfilled
p1 = p0.then(cb_A1)           // queues M_A1 NOW (p0 already settled)
                                // returns p1 (pending until M_A1 runs)
p2 = p1.then(cb_A2)           // p1 is pending → cb_A2 attached but NOT queued yet
p3 = p2.then(cb_A3)           // p2 is pending → cb_A3 attached but NOT queued yet

So at the end of the sync phase, the microtask queue has just two entries — M_A1 and M_B1 — even though we wrote six .then calls. The other four are attached as continuation handlers on pending promises, waiting for their parent to settle.

When M_A1 runs and returns, p1 settles — and only then does M_A2 get queued. But by that point, M_B1 is also in the queue (it was queued from the start), so the runtime processes them in order. The queue at each tick looks like:

after sync         [M_A1, M_B1]
M_A1 runs          [M_B1, M_A2]   // A2 added at the back
M_B1 runs          [M_A2, M_B2]
M_A2 runs          [M_B2, M_A3]
M_B2 runs          [M_A3, M_B3]
M_A3 runs          [M_B3]
M_B3 runs          []
Real-world impact: If you fan out to many promise chains expecting them to run "in parallel and finish in order," they actually round-robin. For two chains of N steps, the cost is 2N microtask ticks — N for each — but interleaved. The total throughput is fine; the surprise is the ordering.

Puzzle 2 — trace

# Phase Action Microtask Q Output
1syncA chain set up — +M_A1; A2, A3 attached as pending handlers[M_A1]
2syncB chain set up — +M_B1; B2, B3 attached as pending handlers[M_A1, M_B1]
— stack empty → drain microtasks —
3microM_A1: log 'A1'; p1 fulfils → +M_A2[M_B1, M_A2]A1
4microM_B1: log 'B1'; q1 fulfils → +M_B2[M_A2, M_B2]A1 B1
5microM_A2: log 'A2'; p2 fulfils → +M_A3[M_B2, M_A3]A1 B1 A2
6microM_B2: log 'B2'; q2 fulfils → +M_B3[M_A3, M_B3]A1 B1 A2 B2
7microM_A3: log 'A3'[M_B3]A1 B1 A2 B2 A3
8microM_B3: log 'B3'[]A1 B1 A2 B2 A3 B3

Combined trace (both puzzles in one program)

When both puzzles run back-to-back from the same file, their queue state mixes. M4 (the puzzle-1 microtask) was queued before the puzzle-2 chains started setting up — so it drains first.

# Phase Action Microtask Q Output
1synclog '1'[]1
2syncexecutor → log '2', resolve(), log '3'[]1 2 3
3sync.then(cb_4)+M_4[M_4]1 2 3
4synclog '5'[M_4]1 2 3 5
5syncA chain set up → +M_A1[M_4, M_A1]1 2 3 5
6syncB chain set up → +M_B1[M_4, M_A1, M_B1]1 2 3 5
— stack empty → drain microtasks —
7microM_4: log '4'[M_A1, M_B1]1 2 3 5 4
8microM_A1: log 'A1'+M_A2[M_B1, M_A2]1 2 3 5 4 A1
9microM_B1: log 'B1'+M_B2[M_A2, M_B2]1 2 3 5 4 A1 B1
10microM_A2: log 'A2'+M_A3[M_B2, M_A3]1 2 3 5 4 A1 B1 A2
11microM_B2: log 'B2'+M_B3[M_A3, M_B3]1 2 3 5 4 A1 B1 A2 B2
12microM_A3: log 'A3'[M_B3]1 2 3 5 4 A1 B1 A2 B2 A3
13microM_B3: log 'B3'[]1 2 3 5 4 A1 B1 A2 B2 A3 B3

Connection to async/await

The chain-interleave puzzle is the same problem dressed up differently:

async function a() {
  console.log('A1');
  await 0;                  // microtask boundary #1
  console.log('A2');
  await 0;                  // microtask boundary #2
  console.log('A3');
}
async function b() { /* same with B1, B2, B3 */ }
a(); b();

Output: A1 B1 A2 B2 A3 B3 — same interleave. Every await is a microtask checkpoint, so two parallel async functions take turns at each await exactly like two .then chains do at each step.

Generalisation: async function is just a syntactic skin over Promise chaining. The number of awaits ≈ the number of microtask ticks needed for that function to complete.

A subtle V8 optimisation

Until V8 7.2 (Chrome 73 / Node 11), await on an already-resolved promise cost two microtask ticks because of how the spec was originally written. Modern V8 — including everything you'll run on Node 22+ — collapses it to one. So this puzzle is more counter-intuitive on old runtimes (more interleave steps) and cleaner on new ones.

Mental model corrections

What's actually synchronous in async code

  • The Promise executor body. Runs immediately on the current stack frame.
  • Code after resolve() or reject(). Still in the executor → still sync.
  • The .then(cb) call itself. Attaching the callback is sync — it's just a function call that registers cb on the promise. Only the callback invocation is deferred.
  • An async function up to its first await. The function body runs sync until you hit an await — at which point the rest is rewritten as a .then.

What "a chain" actually is

A .then().then().then() chain is not a single deferred unit. It's N independent registrations that flow forward as each step settles. Two chains running concurrently won't drain one then the other — they round-robin tick by tick.

How to think about it

  1. One .then = one microtask tick. Count ticks, not chains.
  2. Resolve doesn't return. resolve() is more like "set a flag" than "return from this function".
  3. Microtask queue is FIFO across all queueing sources. Promise.then, queueMicrotask, MutationObserver — all share one queue, processed in registration order.

Try this & further reading

Exercise: Predict the output of Promise.resolve().then(() => console.log('X')).then(() => console.log('Y'));
queueMicrotask(() => console.log('Z'));
— then verify. (Answer: X Z Y. queueMicrotask and Promise.then share one queue; the second .then is registered on a pending promise so its microtask is queued only after X runs.)