▸ 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:
- 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() })
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 |
|---|---|---|---|---|
| 1 | sync | log '1' | [] | 1 |
| 2 | sync | enter executor → log '2' | [] | 1 2 |
| 3 | sync | resolve() — promise marked fulfilled | [] | 1 2 |
| 4 | sync | log '3' (still in executor!) | [] | 1 2 3 |
| 5 | sync | .then(cb_4) on fulfilled promise → queue M4 | [M4] | 1 2 3 |
| 6 | sync | log '5' | [M4] | 1 2 3 5 |
| — stack empty → drain microtasks — | ||||
| 7 | micro | M4: 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 → []
▸ Puzzle 2 — trace
| # | Phase | Action | Microtask Q | Output |
|---|---|---|---|---|
| 1 | sync | A chain set up — +M_A1; A2, A3 attached as pending handlers | [M_A1] | ∅ |
| 2 | sync | B chain set up — +M_B1; B2, B3 attached as pending handlers | [M_A1, M_B1] | ∅ |
| — stack empty → drain microtasks — | ||||
| 3 | micro | M_A1: log 'A1'; p1 fulfils → +M_A2 | [M_B1, M_A2] | A1 |
| 4 | micro | M_B1: log 'B1'; q1 fulfils → +M_B2 | [M_A2, M_B2] | A1 B1 |
| 5 | micro | M_A2: log 'A2'; p2 fulfils → +M_A3 | [M_B2, M_A3] | A1 B1 A2 |
| 6 | micro | M_B2: log 'B2'; q2 fulfils → +M_B3 | [M_A3, M_B3] | A1 B1 A2 B2 |
| 7 | micro | M_A3: log 'A3' | [M_B3] | A1 B1 A2 B2 A3 |
| 8 | micro | M_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 |
|---|---|---|---|---|
| 1 | sync | log '1' | [] | 1 |
| 2 | sync | executor → log '2', resolve(), log '3' | [] | 1 2 3 |
| 3 | sync | .then(cb_4) → +M_4 | [M_4] | 1 2 3 |
| 4 | sync | log '5' | [M_4] | 1 2 3 5 |
| 5 | sync | A chain set up → +M_A1 | [M_4, M_A1] | 1 2 3 5 |
| 6 | sync | B chain set up → +M_B1 | [M_4, M_A1, M_B1] | 1 2 3 5 |
| — stack empty → drain microtasks — | ||||
| 7 | micro | M_4: log '4' | [M_A1, M_B1] | 1 2 3 5 4 |
| 8 | micro | M_A1: log 'A1' → +M_A2 | [M_B1, M_A2] | 1 2 3 5 4 A1 |
| 9 | micro | M_B1: log 'B1' → +M_B2 | [M_A2, M_B2] | 1 2 3 5 4 A1 B1 |
| 10 | micro | M_A2: log 'A2' → +M_A3 | [M_B2, M_A3] | 1 2 3 5 4 A1 B1 A2 |
| 11 | micro | M_B2: log 'B2' → +M_B3 | [M_A3, M_B3] | 1 2 3 5 4 A1 B1 A2 B2 |
| 12 | micro | M_A3: log 'A3' | [M_B3] | 1 2 3 5 4 A1 B1 A2 B2 A3 |
| 13 | micro | M_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.
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
Promiseexecutor body. Runs immediately on the current stack frame. - Code after
resolve()orreject(). Still in the executor → still sync. - The
.then(cb)call itself. Attaching the callback is sync — it's just a function call that registerscbon the promise. Only the callback invocation is deferred. - An
async functionup to its firstawait. The function body runs sync until you hit anawait— 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
- One
.then= one microtask tick. Count ticks, not chains. - Resolve doesn't return.
resolve()is more like "set a flag" than "return from this function". - Microtask queue is FIFO across all queueing sources. Promise.then, queueMicrotask, MutationObserver — all share one queue, processed in registration order.
▸ Try this & further reading
- Run it:
node src/js-q-2.jsfrom the project root. - Revisit Part 1 — A H D F G B C E for the sync/micro/macro interplay with timers.
- Continue to Part 3 — await migration for how all of this maps onto async/await syntax and what to watch when migrating from
.then. - ECMA-262 — Promise Resolve Functions (spec; technical)
- V8 blog — Faster async functions and promises (covers the
awaitoptimisation) - Jake Archibald — await vs return vs return await (three subtly different ways to wait)
- MDN — Promise
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.)