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