The Microtask Queue: A Complete Proof of Execution Order
Being able to accurately predict the output order of any combination of Promise, async/await, and setTimeout means you have a precise mental model of the JavaScript runtime. That ability doesn't come from memorizing example outputs โ it comes from understanding the state-machine behavior of the microtask queue.
๐น Level 1 ยท What You Need to Know
Three Rules for Predicting Output
Rule 1: Synchronous code runs first.
All synchronous code (excluding everything after an await) runs before any microtask.
Rule 2: Microtasks drain completely before the next macro-task.
Promise.then, queueMicrotask, and MutationObserver callbacks are all microtasks. After each macro-task (including the script's initial execution), every accumulated microtask drains โ including microtasks spawned by other microtasks.
Rule 3: await pauses the current function; it is conceptually equivalent to .then.
async function f() {
await expr;
afterAwait(); // everything after await = code inside .then
}
Three-Line Mnemonic
Sync โ Microtasks (drain completely) โ Macro-task (one) โ Microtasks (drain) โ โฆ
Simplest Verification
Promise.resolve().then(() => console.log('micro-1'));
queueMicrotask(() => console.log('micro-2'));
setTimeout(() => console.log('macro'), 0);
console.log('sync');
// Output: sync โ micro-1 โ micro-2 โ macro
Both microtasks execute in registration order, both before the macro-task.
Quick async/await Reading
async function a() {
console.log('a1'); // sync
await b(); // call b, then pause a
console.log('a3'); // microtask: a's continuation
}
async function b() {
console.log('b1'); // sync (runs during a's await expression)
}
console.log('start');
a();
console.log('end');
// Output: start โ a1 โ b1 โ end โ a3
Key: await b() runs b() synchronously first, then pauses a; control returns to the caller (printing end); then the microtask resumes a (printing a3).
๐ธ Level 2 ยท How It Actually Works
Each puzzle uses a three-column table to track: call stack, microtask queue, and task queue.
Puzzle 1: setTimeout + Promise.then Mix
setTimeout(() => console.log('T'), 0);
Promise.resolve()
.then(() => console.log('M1'))
.then(() => console.log('M2'));
console.log('S');
Execution trace:
| Step | Call Stack | Microtask Queue | Task Queue | Output |
|---|---|---|---|---|
| 1 | [main] |
[] |
[] |
โ |
| 2 | setTimeout called | [] |
[T_cb] |
โ |
| 3 | .then('M1') registers |
[M1_cb] |
[T_cb] |
โ |
| 4 NOTE | .then('M2') NOT yet registered โ M1_cb hasn't run, so M2's Promise is still pending |
[M1_cb] |
[T_cb] |
โ |
| 5 | console.log('S') |
[M1_cb] |
[T_cb] |
S |
| 6 | Sync ends; drain microtasks: run M1_cb | [] |
[T_cb] |
M1 |
| 7 | M1_cb returns undefined; M2 registers | [M2_cb] |
[T_cb] |
โ |
| 8 | Drain: run M2_cb | [] |
[T_cb] |
M2 |
| 9 | Microtask queue empty; run macro-task T_cb | [] |
[] |
T |
Output: S โ M1 โ M2 โ T
Note: In a .then chain, the second .then registers its microtask only after the first .then callback finishes โ they are not both registered simultaneously.
Puzzle 2: Precise Microtask Position of async/await
async function foo() {
console.log('foo-1');
const result = await Promise.resolve('hello');
console.log('foo-2', result);
return 'foo-done';
}
async function bar() {
console.log('bar-1');
const result = await foo();
console.log('bar-2', result);
}
console.log('start');
bar();
console.log('end');
Desugared for analysis:
function bar() {
console.log('bar-1');
return foo().then(result => { console.log('bar-2', result); });
}
function foo() {
console.log('foo-1');
return Promise.resolve('hello').then(result => {
console.log('foo-2', result);
return 'foo-done';
});
}
| Step | Action | Output | Microtask Queue |
|---|---|---|---|
| 1 | console.log('start') |
start | [] |
| 2 | Call bar(), enter bar |
โ | โ |
| 3 | console.log('bar-1') |
bar-1 | [] |
| 4 | Call foo(), enter foo |
โ | โ |
| 5 | console.log('foo-1') |
foo-1 | [] |
| 6 | Promise.resolve('hello').then(foo_cont) registers |
โ | [foo_cont] |
| 7 | foo returns pending Promise; bar's await pauses |
โ | [foo_cont] |
| 8 | console.log('end') |
end | [foo_cont] |
| 9 | Sync ends; drain: run foo_cont |
foo-2 hello | [] |
| 10 | foo_cont returns 'foo-done'; foo's Promise fulfills |
โ | [bar_cont] |
| 11 | Drain: run bar_cont |
bar-2 foo-done | [] |
Output: start โ bar-1 โ foo-1 โ end โ foo-2 hello โ bar-2 foo-done
Puzzle 3: Accumulated Microtasks from Multiple .then
const p = Promise.resolve();
p.then(() => console.log('A'));
p.then(() => console.log('B'));
p.then(() => console.log('C'));
p.then(() => {
console.log('D');
Promise.resolve().then(() => console.log('E'));
});
All four .then calls are attached directly to the same already-fulfilled Promise p โ not chained.
| Step | Action | Microtask Queue | Output |
|---|---|---|---|
| Initial | All 4 .then register simultaneously |
[A_cb, B_cb, C_cb, D_cb] |
โ |
| 1 | Run A_cb | [B_cb, C_cb, D_cb] |
A |
| 2 | Run B_cb | [C_cb, D_cb] |
B |
| 3 | Run C_cb | [D_cb] |
C |
| 4 | Run D_cb; registers new microtask | [E_cb] |
D |
| 5 | Run E_cb (within the same drain) | [] |
E |
Output: A โ B โ C โ D โ E
Key point: E_cb is appended to the tail of the queue currently being drained, so it executes before any macro-task gets a chance.
Puzzle 4: Extra Microtasks from Promise.resolve(thenable)
let resolveP;
const p = new Promise(r => { resolveP = r; });
p.then(v => console.log('Handler:', v));
resolveP(Promise.resolve(42)); // resolve a thenable!
Promise.resolve().then(() => console.log('Plain micro'));
| Step | Action | Microtask Queue | Output |
|---|---|---|---|
| 1 | resolveP(Promise.resolve(42)) โ thenable triggers NPRTR job |
[NPRTR_job] |
โ |
| 2 | Promise.resolve().then registers |
[NPRTR_job, Plain_cb] |
โ |
| 3 | Sync ends; run NPRTR_job |
[Plain_cb] |
โ |
| 4 | NPRTR calls inner.then(resolveP, rejectP) |
[Plain_cb, resolveP_cb] |
โ |
| 5 | Run Plain_cb |
[resolveP_cb] |
Plain micro |
| 6 | Run resolveP_cb(42); p becomes fulfilled |
[Handler_cb] |
โ |
| 7 | Run Handler_cb |
[] |
Handler: 42 |
Output: Plain micro โ Handler: 42
(NPRTR = NewPromiseResolveThenableJob)
Contrast: If you use resolveP(42) (plain value, not thenable), the output would be Handler: 42 โ Plain micro, because the Handler microtask enters the queue first.
Full Combined Puzzle: Classic Hard Interview Question
console.log('1');
setTimeout(function () {
console.log('2');
Promise.resolve().then(function () {
console.log('3');
});
}, 0);
new Promise(function (resolve) {
console.log('4');
resolve();
}).then(function () {
console.log('5');
setTimeout(function () {
console.log('6');
}, 0);
});
console.log('7');
| Step | Call Stack | Microtask Queue | Task Queue | Output |
|---|---|---|---|---|
| 1 | main |
[] |
[] |
1 |
| 2 | setTimeout registers | [] |
[cb2] |
โ |
| 3 | Promise constructor (sync) | [] |
[cb2] |
4 |
| 4 | .then registers cb5 | [cb5] |
[cb2] |
โ |
| 5 | console.log('7') | [cb5] |
[cb2] |
7 |
| 6 | Sync ends; drain: run cb5 | [] |
[cb2] |
5 |
| 7 | cb5 registers setTimeout | [] |
[cb2, cb6] |
โ |
| 8 | Microtasks empty; run macro cb2 | [] |
[cb6] |
2 |
| 9 | cb2 registers Promise.then | [cb3] |
[cb6] |
โ |
| 10 | cb2 ends; drain: run cb3 | [] |
[cb6] |
3 |
| 11 | Microtasks empty; run macro cb6 | [] |
[] |
6 |
Output: 1 โ 4 โ 7 โ 5 โ 2 โ 3 โ 6
Two ASCII State Diagrams
Diagram 1: Dynamic Queue Expansion During Microtask Drain
Sync execution phase:
Call stack: [main]
Microtasks: []
Tasks: []
โ setTimeout() Tasks: [T]
โ Promise.then() Microtasks: [M1]
โ ... (sync continues)
After sync ends โ microtask drain phase:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Microtask queue: [M1, M2, M3] โ
โ โ dequeue M1 โ
โ Execute M1 โ
โ (M1 may produce M4) โ
โ Microtask queue: [M2, M3, M4] โ
โ โ dequeue M2 โ
โ Execute M2 โ
โ ... โ
โ Until queue is empty โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Then execute macro-task T (one)
Then drain microtasks again
...repeat
Diagram 2: Call Stack Nesting with async/await
console.log('start')
โ
bar() invoked โโโโโโโโโโโโโโโโโโโโโโโ
โ โ
โ Stack: [main, bar] โ
โ console.log('bar-1') โ
โ โ
โ foo() invoked โโโโโโโโโโโโโ โ
โ โ โ
โ Stack: [main, bar, foo] โ โ
โ console.log('foo-1') โ โ
โ await โ foo pauses โ โ
โ foo frame pops โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ
โ bar receives pending Promise โ
โ bar pauses โ
โ bar frame pops โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
console.log('end') โ sync continues
โ
โ (sync ends)
Microtask drain:
foo continuation โ prints 'foo-2'
bar continuation โ prints 'bar-2'
๐บ Level 3 ยท How the Spec Defines It
When EnqueueJob Is Called
In the ECMAScript spec, EnqueueJob is called in these situations:
1. Enqueuing PromiseReactionJob:
When a Promise's state changes from pending to fulfilled or rejected, TriggerPromiseReactions iterates the reactions list and calls EnqueueJob for each reaction.
2. Enqueuing NewPromiseResolveThenableJob:
When the resolve function receives a thenable, EnqueueJob("PromiseJobs", NewPromiseResolveThenableJob, ...) introduces an extra microtask level.
Spec text (ยง27.2.2.1):
NewPromiseResolveThenableJob ( promiseToResolve, thenable, then )
- Let job be a new Job Abstract Closure with no parameters that captures promiseToResolve, thenable, and then and performs the following steps when called: a. Let resolvingFunctions be CreateResolvingFunctions(promiseToResolve). b. Let thenCallResult be Completion(Call(then, thenable, ยซ resolvingFunctions.[[Resolve]], resolvingFunctions.[[Reject]] ยป)). c. If thenCallResult is an abrupt completion, then: i. Perform ? Call(resolvingFunctions.[[Reject]], undefined, ยซ thenCallResult.[[Value]] ยป). ii. Return unused. d. Return ? thenCallResult.
HTML Spec: Microtask Checkpoint Trigger Conditions
The HTML spec specifies that a Microtask Checkpoint occurs not only after each macro-task but also:
- After script evaluation ends (when a
<script>tag finishes) - After certain Web API completions (fetch response body processing, etc.)
- After WebSocket message handling
From HTML spec ยง8.1.7.4:
The microtask checkpoint must be performed in the following situations:
- When the event loop's current task is null
- After a script's ScriptEvaluationJob completes
- Before returning from any "spin the event loop" algorithms
Key: queueMicrotask itself only calls EnqueueJob โ it does not immediately trigger a checkpoint. The checkpoint is triggered by the event loop when the current macro-task ends.
The await Spec Semantics Change
ECMAScript 2020 introduced a normative change (implemented in V8 v7.2) that reduced await from 2 microtask levels to 1:
Old spec (ES2018) โ equivalent to:
// await v desugars to:
Promise.resolve(v).then(PromiseResolveFunction).then(continuation)
// 2 microtask levels
New spec (ES2020+) โ equivalent to:
// await v (when v is not a thenable):
Promise.resolve(v).then(continuation)
// 1 microtask level
The key change: when await's operand is already a native Promise, the engine skips NewPromiseResolveThenableJob and directly uses PerformPromiseThen to connect the continuation. This makes await nativePromise cost only 1 microtask.
๐ Level 4 ยท Edge Cases and Traps
Trap 1: How Many .then Calls Does await Equal? (Spec History Trap)
async function test() {
await 1;
return 'done';
}
const p = test();
Promise.resolve().then(() => console.log('A'));
p.then(() => console.log('B'));
// Node.js 10 (old spec): A โ B
// Node.js 12+ (new spec): B โ A
// Old spec: await 1 costs 2 microtask levels; B runs after A
// New spec: await 1 (primitive) costs 1 microtask level; B runs before A
This was a real breaking change that affected test suites and framework code relying on specific microtask ordering.
Trap 2: Consequences of Infinite queueMicrotask Recursion
let count = 0;
function recurse() {
count++;
if (count < 1_000_000) {
queueMicrotask(recurse);
} else {
console.log('finally done');
}
}
queueMicrotask(recurse);
setTimeout(() => console.log('macro'), 0);
// The setTimeout callback runs only after 1,000,000 microtasks complete
// Remove the termination condition and setTimeout never runs
Real consequences:
- Browser UI completely frozen (render frames starved)
- DevTools may report "long task"
- Deep microtask nesting can create memory pressure in some engines
Trap 3: MutationObserver Microtask Timing
const observer = new MutationObserver(() => {
console.log('mutation observed');
});
const div = document.createElement('div');
document.body.appendChild(div);
observer.observe(div, { attributes: true });
Promise.resolve().then(() => console.log('promise'));
div.setAttribute('data-x', '1'); // triggers MutationObserver
console.log('sync');
// Output: sync โ mutation observed โ promise
MutationObserver callbacks are scheduled as microtasks in browsers, but their enqueue time is tied to the DOM mutation โ which happens inside setAttribute, before the Promise.resolve().then was registered.
Note: This is browser-specific behavior. MutationObserver callback priority relative to Promise may vary slightly across browser implementations.
Trap 4: Node.js setImmediate vs. setTimeout(fn, 0) โ Non-deterministic Order
// In Node.js main module (top-level code):
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// Order is non-deterministic! May be:
// timeout โ immediate
// or: immediate โ timeout
Why: setTimeout(fn, 0) is actually setTimeout(fn, 1) (libuv minimum precision). If the timers phase is entered after 1 ms has elapsed, timeout fires first. Otherwise, the timers phase finds no expired timers, continues through poll, enters the check phase (setImmediate), and immediate fires first.
Stable rule: Inside an I/O callback, setImmediate always runs before setTimeout:
const fs = require('fs');
fs.readFile('/tmp/test', () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// Output: immediate โ timeout (stable inside I/O callbacks)
});
Trap 5: Using queueMicrotask for Earlier Scheduling Than setTimeout(0)
// Goal: run something after current sync code ends,
// but before the next macro-task
// Option 1: setTimeout(0) โ macro-task, delays by potentially several ms
setTimeout(() => updateUI(), 0);
// Option 2: queueMicrotask โ microtask, runs immediately after current macro-task
queueMicrotask(() => updateUI());
// Option 3: Promise.resolve().then โ equivalent to queueMicrotask
Promise.resolve().then(() => updateUI());
Real-world application โ Vue 3's nextTick implementation:
// Vue 3 source (simplified)
let resolvedPromise = Promise.resolve();
export function nextTick(fn?: () => void): Promise<void> {
const p = currentFlushPromise || resolvedPromise;
return fn ? p.then(fn) : p;
}
Vue's reactivity system schedules DOM updates via Promise microtasks, ensuring updates happen after all state changes within the same "task" but before the next render macro-task.
Summary
- In a
.thenchain, each callback is an independent microtask registered in sequence โ not all registered at once. - Multiple
.thencalls on the same already-fulfilled Promise (not chained) all register their microtasks simultaneously in registration order. MutationObserveris a microtask in browsers; its timing is tied to the DOM mutation event, which may precede Promise microtasks registered later.await v(non-thenable primitive) costs only 1 microtask in ES2020+ engines, affecting relative ordering withPromise.resolve().- Node.js
setImmediatevssetTimeout(0)order is non-deterministic at the top level; stable only inside I/O callbacks (setImmediate wins).