The microtask checkpoint nobody explains
Every senior FE dev knows Promise.then runs before setTimeout. Almost nobody can point to the exact step in the HTML spec that makes it true — or explain why queueMicrotask inside a microtask doesn't yield. Here's the walk-through, straight from ECMA-262 and WHATWG.
There's a diagram you've seen a hundred times.
Call stack → Web APIs → Macrotask queue → Microtask queue → Back to the stack.
It's not wrong. But it's missing the one detail that explains everything weird you've ever seen with Promise timing.
The detail is something called the microtask checkpoint. It's a step in the HTML spec that fires at four specific moments — not randomly, and not between every task. Knowing those four moments is the difference between "I use Promise.then" and "I can debug why my await fired before my setTimeout(0) even though the timer was set first".
This post walks the spec. By the end you'll be able to stare at code like this and tell me, line-by-line, which output happens when:
(async () => {
console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve().then(() => console.log("C"));
queueMicrotask(() => console.log("D"));
await Promise.resolve();
console.log("E");
})();(The answer, with proof, is at the bottom.)
A microtask checkpoint is an algorithm in the HTML spec named "perform a microtask checkpoint". It drains the microtask queue to completion — if a microtask queues another microtask, that one also runs in the same drain. The checkpoint fires after every task, after every <script> element finishes, and at a few other well-defined moments. That's why Promise.then feels synchronous-ish: the runtime never gives control back to the browser between your Promise callbacks.
The shape of the runtime
Before we read any spec, a ten-second recap, grounded in the diagram you've seen me build for the portfolio's front page:
That animation is deliberately simplified. Each token represents a unit of scheduled work. The macrotask queue is FIFO. The microtask queue is FIFO. The event loop picks one macrotask, runs it to completion, drains microtasks, then picks the next macrotask.
That drain in the middle is the thing. That's the checkpoint. Let's go find it in the spec.
Priority vs. order: clearing up the common mix-up
Before we dive into the spec text, there's a framing trap that catches almost everyone. You've probably read both of these and wondered which is right:
- "Microtasks have higher priority than macrotasks."
- "The event loop picks one macrotask, runs it, then drains microtasks, then picks the next macrotask."
Both are correct. They describe the same mechanism from different angles. Neither is "more right" than the other — and if you've been mentally flipping a coin between them, here's how to reconcile.
The actual event-loop algorithm from the HTML Living Standard's event-loop "Processing model" is this (abbreviated pseudocode):
event-loop:
1. taskQueue = pick a task queue with runnable tasks
2. oldestTask = dequeue from taskQueue
3. run oldestTask to completion ← one macrotask
4. perform-a-microtask-checkpoint() ← drain ALL microtasks
5. if needs rendering: update-the-rendering
6. goto event-loopStep 4 is the while (microtask queue not empty) loop we saw above — it drains everything, including microtasks that were queued during the drain itself.
So the real order every cycle is: one task → drain all microtasks → next task → drain all microtasks → …
"Higher priority" means one thing only: once a microtask is queued, the runtime drains it before picking the next task. It doesn't interrupt the task that's already running — tasks always run to completion. It just cuts the line ahead of every future task.
"All microtasks run before any macrotask" — is that true?
Yes, with one caveat. The phrase is precise if you hear it as: "all queued microtasks run before the next macrotask". It's misleading if you hear it as: "microtasks run before any task ever runs". That second reading is impossible. Microtasks are only ever queued from inside code that is already running as part of some task.
If you've read a blog that says "when the call stack is empty, (1) drain microtasks, (2) pick a macrotask, (3) drain microtasks, repeat" — and filed that as the truth, this is the missing half. The framing isn't wrong; it's incomplete. It skips how the call stack became empty in the first place. The answer: a task was running and finished. The script you write is itself a task. The HTML parser is a task. There is no idle, pre-task phase where microtasks run alone. Every microtask gets scheduled from inside some task that was already running.
There is no "microtasks first, then tasks" boot sequence. The HTML parser is itself executing as part of a task; inline script execution happens inside that parser-driven work. Only code already running inside some task can schedule the first microtask.
So the real sequence on page load looks like:
- HTML parsing is itself running as a task on the event loop.
- The parser reaches an inline
<script>, executes it as part of that parser task. Inside:Promise.resolve().then(a); setTimeout(b, 0); queueMicrotask(c);. - Script execution finishes. "Clean up after running script" performs a microtask checkpoint →
aandcdrain here (inside the nested cleanup's checkpoint, if the JS context stack was cleared; otherwise at the end of the outer task). - The parser task eventually completes. The event loop's own microtask checkpoint fires again (harmless if the queue's already empty).
- Event loop picks the next task — the timer callback
b. Runs it. Drains microtasks. - Continues forever.
At no point does "microtasks first" exist as a phase. They're interleaved task-by-task.
Proof in V8
V8 implements the HTML checkpoint as MicrotaskQueue::PerformCheckpoint, declared in src/execution/microtask-queue.h. It's a thin wrapper. First it checks a re-entrancy guard, then it hands the drain to Execution::TryRunMicrotasks. That function loops over an internal ring buffer until size_ hits zero. A post-drain DCHECK_EQ(0, size()) enforces the invariant that the checkpoint always drains to empty.
Blink's side is the trigger. The call shape is agent.event_loop()->PerformMicrotaskCheckpoint(), fired from several scheduler boundaries (script-execution cleanup, WebIDL callback cleanup, explicit sync points). Whichever site triggers it, the flow is the same: Blink finishes a task, calls through to V8's checkpoint, V8 drains the queue, control returns. The drain is a consequence of a task completing — never parallel, never preemptive.
If you want to read it yourself: search Chromium source for PerformMicrotaskCheckpoint and V8 microtask-queue.cc. The important thing is that the shape matches the spec line-for-line: one task completes → scheduler calls the embedder hook → TryRunMicrotasks drains until empty → control returns to the event loop.
Two framings, same machinery:
- Structural: "One task → drain microtasks → next task."
- Priority: "Any queued microtask runs before any subsequent task."
If either one surprised you, the other's the one to re-read.
See it yourself — don't take my word for it
Hit ▶ run. Then copy the code and paste it into your own browser console to verify.
1console.log("1. sync (script body)");2 3setTimeout(() => console.log("4. macrotask (setTimeout 0)"), 0);4 5Promise.resolve().then(() => console.log("3. microtask (Promise.then)"));6 7console.log("2. sync (script body)");Watch the order: 1, 2, 3, 4. Now see where each one goes — step through the engine:
console.log("1");
setTimeout(() => console.log("4"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("2");What just happened, step by step:
- The script itself is a task on the macrotask queue. The runtime started executing it.
- Line 1 logs
1synchronously. setTimeout(fn, 0)schedules a new task — goes on the task queue, waiting.Promise.resolve().then(fn)schedules a microtask — goes on the microtask queue, waiting.- Line 4 logs
2synchronously. - Script task finishes. Microtask checkpoint fires.
3logs. - Event loop moves to the next task: the setTimeout callback.
4logs.
If the "microtasks run first" model were literally true, you'd expect 3, 1, 2, 4. But microtasks do not preempt the currently executing task — they wait for it to finish.
But microtasks do run before the next macrotask
Now prove the other half of the statement — microtasks genuinely are higher-priority-than-subsequent-tasks. This demo also shows the "while queue not empty" behaviour: a microtask queued inside a microtask still runs in the same drain, ahead of any waiting task.
1console.log("1. sync");2 3setTimeout(() => console.log("5. macrotask"), 0);4 5Promise.resolve().then(() => {6console.log("3. microtask A");7// Queue another microtask INSIDE a microtask.8// It still runs before the macrotask below.9Promise.resolve().then(() => console.log("4. microtask B (queued during drain)"));10});11 12console.log("2. sync");Output: 1, 2, 3, 4, 5.
Key moment: line 4 logs before line 5, even though microtask B didn't exist when the script started. It was queued during the drain. The checkpoint's while loop saw the new microtask, ran it, then the event loop moved on to the setTimeout task.
Walk through it step-by-step — watch the microtask queue refill itself mid-drain:
console.log("1");
setTimeout(() => console.log("5"), 0);
Promise.resolve().then(() => {
console.log("3");
Promise.resolve().then(() => console.log("4"));
});
console.log("2");This is why I said the loop in the "perform a microtask checkpoint" algorithm is "drain to empty, not snapshot". It matters.
Now let's go to the spec text.
The primary source
Two specs are at play. Most mental-model confusion comes from mixing their boundaries:
- ECMA-262 (the JavaScript language) defines Jobs and Job Queues. These are purely a language concept.
Promise.thenandawaitschedule Promise Reaction Jobs throughPerformPromiseThen, which eventually calls the host hookHostEnqueuePromiseJob. - WHATWG HTML (the browser environment) defines the event loop, task queues, and the microtask queue. HTML also defines
queueMicrotask()— this is not an ECMAScript API. It enqueues a microtask directly. HTML's own implementation ofHostEnqueuePromiseJobis "enqueue onto the surrounding agent's event loop's microtask queue" — that's the bridge between the two specs.
The cleaner way to say it:
Promise.then(fn)/await→ ECMAScript Promise Reaction Job →HostEnqueuePromiseJob→ HTML microtask queue.queueMicrotask(fn)→ HTML microtask queue directly.
In browsers they land on the same queue. They just get there through different specs. Everything below is what the HTML side does with that queue.
Here's the bridge paragraph — the microtask-checkpoint algorithm itself:
To perform a microtask checkpoint, given an event loop eventLoop:
- If eventLoop's performing a microtask checkpoint is true, then return.
- Set eventLoop's performing a microtask checkpoint to true.
- While eventLoop's microtask queue is not empty:
- Let oldestMicrotask be the result of dequeuing from eventLoop's microtask queue.
- Set the event loop's currently running task to oldestMicrotask.
- Run oldestMicrotask.
- Set eventLoop's performing a microtask checkpoint to false.
- Cleanup Indexed Database transactions.
- Perform ClearKeptObjects.
Read step 3 slowly. "While the microtask queue is not empty" is a loop condition re-evaluated on every pass. If running one microtask enqueues another, the while keeps going. The checkpoint doesn't snapshot the queue and drain that snapshot. It drains to a real, live empty.
That single word — while — is the reason for every "weird" microtask behaviour. We'll see it bite three ways.
Where a checkpoint actually fires
The checkpoint is invoked from a handful of named algorithms in the HTML spec. The two that matter for day-to-day JavaScript:
- From the event loop's processing model itself — after every task completes, the algorithm steps explicitly call "perform a microtask checkpoint". This is the drain between every pair of tasks.
- From "clean up after running script" and "clean up after running a callback" — both cleanup algorithms call perform-a-microtask-checkpoint as their last step. Scripts get the former, WebIDL callbacks get the latter. That's why your
<script>body feels "atomic" — see below.
A few other less user-facing places also trigger the checkpoint (certain DOM mutation flows, for instance), but if you understand these two, you have 99% of day-to-day behavior covered. Let's pin down the second one — it's the counterintuitive one.
Why your <script> feels "atomic"
When the browser runs a <script> tag (classic script), the spec walks through the script execution algorithm. After the script's synchronous body finishes, the runtime calls "clean up after running script" — whose last step performs a microtask checkpoint:
To clean up after running script with an environment settings object settings:
- Assert: settings's realm execution context is the running JavaScript execution context.
- Remove settings's realm execution context from the JavaScript execution context stack.
- If the JavaScript execution context stack is now empty, perform a microtask checkpoint. (This does nothing if this was a nested invocation.)
(The sibling algorithm "clean up after running a callback" performs the same checkpoint for WebIDL callbacks — e.g., event handlers. Two algorithms, one mechanism.)
This is why:
console.log("A");
Promise.resolve().then(() => console.log("C"));
console.log("B");
// Output: A, B, CC runs before the next line of any other script in the page. When your <script> body ends, the runtime drains microtasks before touching anything else. B is still inside your script, so it prints first. Then the checkpoint fires. Then C runs. Then the event loop moves on.
Now contrast with:
console.log("A");
setTimeout(() => console.log("C"), 0);
console.log("B");
// Output: A, B, CSame output, different mechanism. The setTimeout callback is not a microtask. It's a task, scheduled through the HTML Timers section. It runs on the next event loop cycle. From the outside the result looks identical; in reality it fires a few milliseconds later.
The difference shows up the moment you mix them.
The three "weird" behaviors, now explained
Weird 1 — Microtasks starve macrotasks
function loopForever() {
Promise.resolve().then(loopForever);
}
setTimeout(() => console.log("setTimeout fires!"), 0);
loopForever();Your setTimeout callback never runs. Tab hangs. Why?
Here's the same test, but capped at 1000 iterations so it doesn't actually hang your tab. Watch: the setTimeout callback only runs after all 1000 microtasks have drained — in the sandboxed demo below it eventually does, because we stop adding more. In the real uncapped version, the event loop never reaches its next cycle at all.
1let n = 0;2function loopMicrotask() {3// Uncapped this is infinite. Capped so we can actually see the setTimeout fire.4if (++n > 1000) return;5Promise.resolve().then(loopMicrotask);6}7setTimeout(() => console.log("setTimeout finally fires! n =", n), 0);8loopMicrotask();9console.log("scheduled " + 1 + " setTimeout + an infinite microtask chain");The checkpoint's "while queue is not empty" loop drains to zero. Not to a snapshot. Every loopForever callback enqueues another microtask before it returns, so the while condition is always true. The checkpoint never completes. The event loop never reaches its next iteration. setTimeout's task never gets picked up.
This is why queueMicrotask is dangerous in hot paths, and why React, Vue, and friends schedule work through MessageChannel or setTimeout when they need the runtime to yield.
setTimeout(fn, 0) yields control. Promise.resolve().then(fn) does not. If you ever need "run this later but definitely after the browser has had a chance to paint", you want the former. Microtasks don't paint in between them. We'll cover this in the next post in the series.
Weird 2 — await runs its continuation synchronously-ish
The desugaring rule, from ECMA-262's Await abstract operation:
- Let promise be ? PromiseResolve(%Promise%, value).
- …
- Let onFulfilled be CreateBuiltinFunction of the async context's resumption.
- Perform PerformPromiseThen(promise, onFulfilled, onRejected).
- Return undefined (suspend the async function).
So await x does three things: resolve x to a Promise, attach a then that resumes the async function, suspend. The resume is a Promise reaction job. That's ECMA-262's Job abstraction. The host enqueues it as a microtask.
Given:
async function main() {
console.log("A");
await Promise.resolve();
console.log("B");
}
main();
console.log("C");
// Output: A, C, BA runs synchronously. await Promise.resolve() suspends main, schedules its resume as a microtask. Sync code continues, C prints. Script body ends. Microtask checkpoint fires. B prints.
If you change await Promise.resolve() to await new Promise(r => setTimeout(r, 0)), the output order stays A, C, B — but now B runs on the next event loop cycle as a task, not on this cycle's microtask checkpoint. Two almost-identical lines, two very different scheduling points.
Weird 3 — queueMicrotask inside a microtask is not "next tick"
queueMicrotask(() => {
console.log("outer start");
queueMicrotask(() => console.log("inner"));
console.log("outer end");
});
setTimeout(() => console.log("timer"), 0);
// Output: outer start, outer end, inner, timerThe inner queueMicrotask enqueues into the same microtask queue. The checkpoint's "while queue is not empty" loop sees it, keeps looping, and runs inner before the loop can exit. Then we return to the main event-loop cycle, which picks up the timer task.
If you expected outer start, outer end, timer, inner — that's what you'd get if microtasks were "run once, snapshot-style, yield". They aren't. They're drained to zero every time.
Proof from V8
If you don't trust the spec, look at Chrome's implementation. V8's MicrotaskQueue::PerformCheckpoint (src/execution/microtask-queue.cc) is the embedder hook. It's a thin wrapper: check a re-entrancy guard, then call Execution::TryRunMicrotasks, which drains the internal ring buffer until size_ == 0. The drain ends with a DCHECK_EQ(0, size()) — a hard invariant that it always runs to zero.
Blink is the trigger. Somewhere inside the scheduler (e.g. document_loader.cc), Blink finishes a task, reaches into the agent's event loop, and calls through to V8's checkpoint. No magic, no parallel thread. The drain fires as a direct consequence of the task completing.
Read both for yourself — the one-line takeaway from each file is the same: "while not empty, run one more".
Now you can read any Promise timing puzzle
Back to the opener:
console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve().then(() => console.log("C"));
queueMicrotask(() => console.log("D"));
await Promise.resolve();
console.log("E");Walk it:
- Script starts.
Aprints. Sync log. setTimeoutschedules a task on the macrotask queue.Promise.resolve().then(…)schedules a microtask that will printC.queueMicrotask(…)enqueues a microtask that will printD. Queue is[C, D].await Promise.resolve()suspends the enclosing async function, schedules its resumption (printingE) as a microtask. Queue is[C, D, E].- Script body ends. Checkpoint fires. Drain the queue:
C,D,E. - Script is done. Event loop picks the next task.
Bprints.
Output: A, C, D, E, B.
Notice that E comes before B, even though the setTimeout was scheduled earlier. That's the entire point of the microtask checkpoint. The event loop doesn't round-robin — it drains.
Don't trust me. Run it:
1// The opening puzzle — walk through it in your head, then hit run.2(async () => {3console.log("A");4setTimeout(() => console.log("B"), 0);5Promise.resolve().then(() => console.log("C"));6queueMicrotask(() => console.log("D"));7await Promise.resolve();8console.log("E");9})();Why this actually matters
If you're shipping interfaces where small timing differences bite:
- React 18's automatic batching groups state updates that happen inside the same task. If you call multiple
setStates synchronously — whether in an event handler, a Promise callback, asetTimeout, or anywhere else — React reconciles them once after the current task/callback unwinds. Same task = one render (React 18 release notes). MutationObservercallbacks are microtasks. If your code readsoffsetHeightinside one, you're forcing a layout inside the checkpoint. The browser can't paint until the checkpoint drains. That's the whole argument forResizeObserveroverMutationObserverfor size-sensitive code.- Long-running microtask chains block
requestAnimationFrame. RAF is scheduled outside the microtask queue (HTML "update the rendering"). If your checkpoint never drains, the browser can't reach the rendering step of the event loop cycle. Janks look exactly like microtask starvation.
Next post in The Runtime series: "What await desugars to, from the spec" — we'll take the handwave in the Await abstract operation and turn it into a working state machine, with a live JavaScript implementation you can step through.
Primary sources: