Chapter 27

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 )

  1. 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:

  1. After script evaluation ends (when a <script> tag finishes)
  2. After certain Web API completions (fetch response body processing, etc.)
  3. 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:

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

  1. In a .then chain, each callback is an independent microtask registered in sequence — not all registered at once.
  2. Multiple .then calls on the same already-fulfilled Promise (not chained) all register their microtasks simultaneously in registration order.
  3. MutationObserver is a microtask in browsers; its timing is tied to the DOM mutation event, which may precede Promise microtasks registered later.
  4. await v (non-thenable primitive) costs only 1 microtask in ES2020+ engines, affecting relative ordering with Promise.resolve().
  5. Node.js setImmediate vs setTimeout(0) order is non-deterministic at the top level; stable only inside I/O callbacks (setImmediate wins).
Rate this chapter
4.8  / 5  (4 ratings)

💬 Comments