JavaScript140 min total · 15 parts
JavaScript Core Concepts: Scope, Closures, `this`, and the Event Loop
Part 12 of 15 · ~7 min
The Event Loop: Microtasks vs. Macrotasks
Everything so far has been about mechanism. This chapter is about order — the rules that decide which of several waiting pieces of code runs next, and in what sequence. It is the last mechanism in this reference and the one that explains the timing of everything before it.
There are two queues, and the difference between them is the whole chapter.
- Macrotasks (the specification calls them simply tasks) — an
inputevent callback, asetTimeoutorsetIntervalcallback, a click, an I/O completion. Everything the browser hands you from the outside world. - Microtasks —
.then/.catch/.finallycallbacks, the continuation of a function after anawait, and anything passed toqueueMicrotask. In Node,process.nextTickas well, which runs ahead of even the other microtasks.
And here is the rule that answers nearly every async-ordering question you will ever have:
Once the call stack is empty, nothing macrotask-shaped gets to run until the microtask queue is completely drained — and a microtask that queues another microtask just extends the draining, it doesn't get skipped.
Not the first microtask. All of them. Every time.
Our search box makes this concrete, because a single keystroke touches both queues:
inputEl.addEventListener("input", debounce(handleInput, 300));
async function handleInput(event) {
spinnerEl.hidden = false;
const results = await fetchResults(event.target.value);
renderRows(results);
spinnerEl.hidden = true;
}
The full life of one keypress, in order:
- The key goes down. The browser queues the
inputcallback as a macrotask and waits for the stack to be free. - The stack empties, the callback runs. The debounce wrapper cancels a pending timer and schedules a new one 300 milliseconds out. That is all it does. The stack empties again.
- Roughly 300 milliseconds later, the timer's callback runs as a macrotask.
handleInputstarts, runs synchronously as far as theawait— sospinnerEl.hidden = falsehappens here — and then yields. - The stack empties, the microtask queue is drained, and the browser is free. It paints. The spinner appears at this point, and not before.
- The response comes back. The Promise settles and the rest of
handleInputis queued as a microtask. - As soon as whatever task is currently running finishes, that microtask runs:
renderRowsmutates the DOM, the spinner is hidden. - The browser paints again, and the dropdown appears.
Every timing question about this widget is answered somewhere in that list. Why the spinner cannot appear before step four. Why the results appear one paint after the response, never in the same instant. Why a slow synchronous function anywhere in the program delays every single one of these steps.
The classic ordering demonstration, in our own vocabulary:
function handleInput(event) {
console.log("1: handler starts");
setTimeout(() => console.log("4: debounce timer"), 0); // macrotask
Promise.resolve().then(() => console.log("3: cached result")); // microtask
console.log("2: handler returns");
}
// 1, 2, 3, 4
1 and 2 run synchronously, before anything else gets a chance. The stack clears, the microtask queue empties out (3), and only then does the macrotask — 4 — get to go, despite asking for zero delay. A setTimeout delay only says when a callback becomes runnable; it says nothing about cutting ahead of whatever's already queued as a microtask.
This has a direct, visible consequence for our search box. Remember the memoisation from chapter five — repeat queries come back from a local cache with no network involved. How you deliver that cached value changes what the user sees:
// Delivered as a microtask: renders in the same frame, no visible flicker
return Promise.resolve(cache.get(key));
// Delivered as a macrotask: the browser gets a paint opportunity in between,
// so the user can see the dropdown empty itself and then refill
return new Promise((resolve) => setTimeout(() => resolve(cache.get(key)), 0));
Identical values, identical code paths, two different experiences — because one of them lands before the next paint and the other lands after it.
Microtasks can starve macrotasks
Read the rule again — the engine will not touch a macrotask until the microtask queue is completely empty — and the failure mode writes itself. A microtask that queues another microtask, forever, means the queue never empties, and nothing else ever runs again.
The pathological minimum:
function spin() {
Promise.resolve().then(spin); // queues another microtask, immediately, always
}
spin();
// Timers never fire. Clicks are never delivered. The page never paints again.
You are unlikely to write that by accident. You are much more likely to write this, which is the same thing with a plausible cover story — scoring every page of a large index, one page at a time:
function scoreAllPages(query, page = 0, acc = []) {
if (page >= PAGE_COUNT) return Promise.resolve(acc);
return Promise.resolve(scorePage(query, page)) // already-settled: no I/O, no waiting
.then((rows) => scoreAllPages(query, page + 1, acc.concat(rows)));
}
scorePage is synchronous, so every Promise here is already settled and every .then queues a microtask that queues another microtask. Five hundred pages later the queue has still never been empty. The spinner's tick never fires, the click on the cancel button is never delivered, and nothing paints — and if you are watching a profiler, nothing looks blocked, because technically nothing is. There is no long stack frame anywhere. The thread is busy in a way that the usual "find the slow function" instinct does not catch.
The fix is to give the macrotask queue a turn on purpose, between chunks:
function yieldToTaskQueue() {
return new Promise((resolve) => setTimeout(resolve, 0)); // a real macrotask boundary
}
async function scoreAllPages(query) {
const acc = [];
for (let page = 0; page < PAGE_COUNT; page++) {
acc.push(...scorePage(query, page));
if (page % 20 === 0) await yieldToTaskQueue(); // timers, clicks and paint get a turn
}
return acc;
}
And one correction that catches almost everybody, because it looks like it should work:
for (let page = 0; page < PAGE_COUNT; page++) {
acc.push(...scorePage(query, page));
await Promise.resolve(); // does NOT let the UI breathe
}
await on an already-settled Promise yields to the microtask queue, which is the queue we are already monopolising. Nothing is unblocked; you have simply spelled the starvation loop differently. Letting the browser render requires reaching a macrotask boundary, and only a macrotask can give you one.
Where async/await fits into the queues
Every rule above applies unchanged to async/await, because await is a .then in disguise, right down to which queue it uses:
console.log("1: setting up");
async function loadResults(query) {
console.log("2: loadResults body, still synchronous");
await null; // yields — everything below is now a microtask
console.log("4: resumed, as a microtask");
}
loadResults("clos");
console.log("3: caller carries on");
// 1, 2, 3, 4
Two things worth taking from that.
An async function's body runs synchronously up to its first await. Calling one is not "starting a background job" — the first half of it happens right now, on the current stack, inside the caller's task. This is why spinnerEl.hidden = false in handleInput takes effect in step three of the keystroke timeline rather than later.
await yields even when there's nothing to actually wait for. await null hands control back regardless, and whatever comes after it picks back up as a microtask — the same place it would land if you'd written Promise.resolve(null).then(rest) by hand. There's no shortcut around the queue just because the awaited thing was never really pending.
One precision note for anyone timing things to the tick: awaiting an already-settled native Promise resumes on the next microtask tick, but awaiting a plain value or a custom object with a .then method costs additional ticks, because the engine has to wrap and inspect it first. It almost never matters. When you are working out why two interleaved async functions log in an order you did not expect, it is occasionally the whole answer.