Microtasks and the checkpoint that drains them
The single-queue picture from the first lesson is a simplification — there is a second, higher-priority microtask queue that gets fully drained after every task, and that one refinement is why a Promise callback always beats a zero-delay timer.
Microtasks and the checkpoint that drains them
The first lesson in this module built the event loop as one queue and one rule: stack empty, pull the next task, run it to completion, repeat. That model is correct as far as it goes, but it left a note for later — a callout admitting the picture was a simplification, with a second queue still to come. This is that queue.
Its name is the microtask queue, and it sits above the task queue in priority in a very specific, mechanical sense: after every single task finishes, before the loop is allowed to touch the next task or let the browser render anything, it must completely empty the microtask queue first. Not "get to it eventually" — completely empty, right then, no matter how many microtasks that takes or how many new ones show up along the way. That draining step has a name too: the microtask checkpoint. Once you have that one rule, a whole cluster of async behavior that otherwise looks like arbitrary trivia — why .then() beats setTimeout, why await doesn't block, why a stray recursive Promise chain can freeze a tab with no visible loop anywhere — turns out to be the same fact restated four ways.
A second queue, not a faster task queue
It's tempting to think of microtasks as just "tasks that run sooner." That's close but wrong in a way that matters. The task queue and the microtask queue are genuinely separate structures, and only one kind of thing gets to enqueue into the microtask queue:
- A Promise reaction — the callback you pass to
.then(),.catch(), or.finally()— is scheduled as a microtask the moment the promise it's attached to settles. queueMicrotask(fn)schedulesfnas a microtask directly, with no promise involved at all — it's the explicit, no-frills way to say "run this as a microtask."- A
MutationObservercallback, batching up all the DOM mutations it observed, fires as a microtask. - An
awaitinside an async function is, under the hood, exactly a.then()on a promise — so the code after anawaitresumes as a microtask reaction. (Lesson 6 unpacks async/await down to this mechanism in full; for now, just file it as "another source of microtasks.")
Everything else — timer callbacks, click handlers, fetch completions, message events — still goes into the ordinary task queue from the first lesson. The two queues are populated by disjoint sources and drained by different rules.
The rule, precisely: drain means drain
Here is the exact ordering the loop follows, per turn: run one task to completion, then drain the entire microtask queue, then — maybe — render, then pick the next task. The word to sit with is "entire." When the checkpoint starts, it doesn't just run whatever microtasks happen to already be queued at that instant. It pops one, runs it, and then checks the queue again — and if that microtask itself queued another microtask (a .then() chained onto a promise resolved inside a Promise reaction, say), that new one runs too, in the very same checkpoint, before the loop is allowed to move on. The checkpoint doesn't end until the queue is genuinely, observably empty.
This is the piece that a single-queue model has no room for. In the simple picture from lesson one, "enqueue a task" always means "wait your turn behind whatever's already queued." Microtasks break that: a microtask scheduled during the drain does not wait for the next task — it gets folded into the drain that's already running.
Why a Promise beats a zero-delay timer
This is the classic ordering puzzle, and now it has a mechanical answer instead of a memorized one:
console.log(1);
setTimeout(() => console.log(4), 0);
Promise.resolve().then(() => console.log(3));
console.log(2);
// Logs: 1, 2, 3, 4Walk it as the loop sees it. The top-level script is one task. Running it: console.log(1) fires immediately (logs 1). setTimeout(..., 0) doesn't run anything — it schedules a task for later and moves on. Promise.resolve().then(...) doesn't run its callback either — the promise is already resolved, so the reaction is scheduled as a microtask immediately, and execution moves on. console.log(2) fires (logs 2). The script — the task — is now finished; the stack empties.
Before the loop can even think about the timer's task, it hits the microtask checkpoint. The microtask queue has exactly one entry: the .then() reaction. It runs, logging 3. The queue is now empty, so the checkpoint ends. Only now does the loop go back to the task queue, find the timer's callback waiting, and run it — logging 4.
4 never had a chance to beat 3, regardless of the 0ms delay, because a task is never even considered until the microtask queue from the previous task is fully drained. It isn't that Promises are "faster" than timers in some vague sense — it's that they're a different kind of queue that the loop is contractually obligated to empty first, every single turn.
A microtask scheduling a microtask
The checkpoint's "keep going until actually empty" behavior is easiest to see when one microtask enqueues the next:
console.log('start');
queueMicrotask(() => {
console.log('microtask 1');
queueMicrotask(() => {
console.log('microtask 2');
});
});
console.log('end');
// Logs: start, end, microtask 1, microtask 2start and end run synchronously as the one task. Then the checkpoint begins: it pops "microtask 1," runs it, and while running it, a brand new microtask — "microtask 2" — gets pushed onto the queue. The checkpoint doesn't consider itself done and hand control back to the loop; it checks the queue again, finds "microtask 2" waiting, and runs that too. Only when nothing is left does the checkpoint actually end. If you nested this ten levels deep, all ten would still run before the loop rendered a single frame or looked at the task queue.
The starvation hazard
That "keep going until empty" rule is also exactly what makes microtasks dangerous in a way tasks aren't. Compare the two failure modes:
- A task that schedules another task (say, a
setTimeoutcalling itself) does yield in between. Each task still has to wait its turn behind whatever else is in the task queue, and — critically — the loop still drains microtasks and can still render a frame between one such task and the next. - A microtask that schedules another microtask does not yield. Each new microtask gets folded straight into the checkpoint that's already running, and the checkpoint by definition does not end while the queue has anything in it. If a microtask keeps enqueueing a new microtask forever, the checkpoint never finishes — which means the loop never gets past step two of its own per-turn sequence. No render. No next task. No click, keypress, or scroll gets processed. The page is not "slow" — it is completely and indefinitely stuck, with no obvious
whileloop anywhere in a profiler to blame.
queueMicrotask vs. setTimeout(0) as a choice
Once the mechanism is visible, the two aren't interchangeable defaults — they're a real scheduling decision. Reach for queueMicrotask (or a .then()) when you want your callback to run before anything else gets a turn — before the browser renders, before any pending task runs — for instance, to let a synchronous-looking API finish its current call stack before invoking a callback, without waiting behind whatever else is queued. Reach for setTimeout(fn, 0) when you deliberately want to yield back to the loop — to let a pending render happen, to let queued tasks (including user input already waiting) get their turn — before your code continues. "Run as soon as possible without yielding" and "yield, then run" are opposite intents, and they map exactly onto these two queues.
Where this goes next
Every microtask checkpoint description above included a hedge: "maybe render." That's deliberate — rendering is not something that happens after every task, and the exact rule for when it happens (and why a burst of microtasks can starve it, as seen above) is its own piece of machinery. Where rendering happens in the loop slots the browser's render step precisely into the sequence this lesson has been building.
Go deeper
- MDN — Microtask guide — The reference definition of a microtask, queueMicrotask, and how the microtask queue relates to the task queue this lesson builds on.
- Jake Archibald — Tasks, microtasks, queues and schedules — The classic deep walkthrough of exactly this checkpoint behavior, with the same kind of ordering puzzles worked out in detail.
- WHATWG HTML spec — Microtask queue — The authoritative processing model for 'perform a microtask checkpoint,' including the exact drain-until-empty algorithm this lesson describes in prose.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- Name the four things that schedule a microtask, and contrast them with what goes into the ordinary task queue instead.
- State the per-turn rule precisely: where does the microtask checkpoint sit relative to a task finishing and the next task starting?
- In `console.log(1); setTimeout(()=>log(4)); Promise.resolve().then(()=>log(3)); log(2);`, walk through why the output is 1, 2, 3, 4 rather than 1, 2, 4, 3.
- If a microtask callback itself calls queueMicrotask, does that new microtask wait for the next task, or does it run before the loop moves on? Why?
- Explain the starvation hazard: why does a microtask that keeps scheduling a new microtask hang the page, while a task that keeps scheduling a new task does not (as badly)?
- When would you deliberately choose setTimeout(fn, 0) over queueMicrotask(fn), given that the timer is 'slower'?
- The first lesson's callout mentioned a single-queue simplification. What exactly was missing from that model, in terms of what you now know about the checkpoint?