Microtasks vs macrotasks
Why the two-queue design exists, the exact ordering rules, how await splits a function, and the performance traps each queue introduces.
The two-queue design
Most async runtimes have a single queue: jobs go in, the loop runs them in order. JavaScript chose to have two queues, and the choice shapes everything about how promises, async/await, and DOM events feel.
Macrotasks are the public API for "schedule a unit of work." Microtasks are the engine's internal mechanism for "the current logical operation needs a continuation, run it before yielding to anything else." When the Promise A+ spec was being written in 2012, the question was: when a promise resolves, when should .then run? Three options were on the table:
- Synchronously, inside the
resolve()call. Rejected because it leaks the order in which you attach handlers and causes "Zalgo" (Domenic Denicola's term for non-deterministic sync/async APIs). - As a macrotask via
setTimeout(fn, 0). Rejected because it is slow (4ms floor) and the browser would render and accept input between resolution and the handler. - As a microtask, drained after the current task. Picked. It is fast, deterministic, and never re-enters user code.
That choice is why await feels seamless. The continuation runs as soon as the value is ready, without giving the browser a chance to interleave anything else. It also means a tight await loop on resolved promises is a microtask flood.
Exact ordering rules
The HTML spec algorithm for one event loop iteration:
- Let
oldestTaskbe the result of popping the oldest task from a task queue (the user agent picks which queue). - Set the currently running task to
oldestTask. - Run
oldestTaskto completion. - Set the currently running task to null.
- Perform a microtask checkpoint.
- Update the rendering if this is a rendering opportunity.
- If this was a window event loop and there are no tasks left, idle until one arrives.
The microtask checkpoint algorithm:
- If the microtask queue is empty, return.
- Set the currently running task to "microtask."
- Loop: while the microtask queue is not empty, dequeue and run.
- Notify about rejected promises that have no handlers.
- Clean up indexed databases.
- Return.
The key word is "loop." If a microtask queues more microtasks, the engine keeps draining. There is no escape until the queue empties or the page is killed.
await: where the queues collide
await is sugar for .then. That is literal, not approximate. The engine takes everything after the await and wraps it in a function passed to .then on the awaited value. The wrapper is a microtask. The split is invisible in source but observable at runtime.
async function foo() {
console.log("a");
await null;
console.log("b");
}
foo();
console.log("c");
// a, c, ba runs synchronously inside the call to foo. The await null wraps null in a resolved promise and schedules the continuation as a microtask. The function returns. c runs next on the stack. Then the microtask checkpoint runs b.
V8 optimized this in 2018. The original spec required two microtask hops for await (one to wrap the value, one for the continuation). V8 collapsed it to one for the common case where the awaited value is already a promise. This made async functions roughly as fast as raw promises, which is why modern JS code uses async/await everywhere without a performance penalty.
Starvation
A microtask cannot be interrupted. If you queue microtasks faster than the checkpoint drains them, the browser never renders, never handles input, never fires timers. This is microtask starvation.
function spin() {
queueMicrotask(spin);
}
spin();This freezes the tab. The same with setTimeout(spin, 0) does not, because each timer fire is a separate macrotask, and the browser gets to render and accept input between them.
The takeaway: be careful with recursive promise chains in hot loops. If you process a stream of items and each item resolves a promise immediately, you can accidentally microtask-flood the queue. The fix is to yield to a macrotask every N items:
async function processBig(items) {
for (let i = 0; i < items.length; i++) {
process(items[i]);
if (i % 100 === 0) {
await new Promise(resolve => setTimeout(resolve, 0));
}
}
}Rendering and the 16ms budget
Browsers try to render at the display refresh rate. On a 60Hz screen that is 16.67ms per frame. The event loop only renders between macrotasks. So if a macrotask plus its microtask checkpoint takes longer than 16ms, you drop a frame.
This is why long tasks are bad. The Long Task API surfaces any task over 50ms. The INP (Interaction to Next Paint) metric measures the worst input-to-paint latency on the page. Both metrics are driven by macrotask duration, which includes the microtask drain. A 30ms macrotask with a 100ms microtask chain registers as a 130ms long task.
requestAnimationFrame is the right hook for visual updates. It runs after microtasks, after the resize and scroll steps, right before layout and paint. RAF callbacks are part of the rendering opportunity, not a macrotask. If you mutate DOM in RAF, the paint that follows in the same loop iteration shows your changes.
Node.js differences
Node has the same microtask rule with two additions: process.nextTick and a phased macrotask loop.
process.nextTick is Node-only. It is more aggressive than microtasks: nextTick callbacks drain before promise microtasks. Use it for "run after the current operation but before anything else, including promise reactions." The official advice is to prefer queueMicrotask unless you specifically need nextTick semantics, because nextTick can starve I/O even faster than microtasks.
Node's macrotask loop has phases (timers, pending, poll, check, close). Between every callback in every phase, both nextTick and microtask queues drain. So a single I/O callback can spawn an unbounded amount of nextTick and microtask work before Node moves to the next I/O event.
process.nextTick(() => console.log("nextTick"));
Promise.resolve().then(() => console.log("promise"));
setImmediate(() => console.log("immediate"));
setTimeout(() => console.log("timeout"), 0);
console.log("sync");
// sync, nextTick, promise, timeout (or immediate), immediate (or timeout)When the distinction bites you in production
The most common production bug from this: a service that runs heavy promise-based processing in a request handler starves its own keep-alive and health-check timers. The load balancer marks the instance unhealthy and pulls traffic. The instance is not actually broken, it is just not draining microtasks fast enough to fire the timers.
The fix is the same as for browser starvation: break work into macrotask-sized chunks. setImmediate is the Node equivalent of "yield to the loop." Use it inside long-running promise chains to give I/O callbacks and timers a chance to run.
Mental model that locks it in
A task is a contract: "I will finish this before doing anything else." A microtask is "I will finish this thought before yielding the task." Microtasks are inside tasks. They are not peers.
When you see await, read it as "save the rest of this function as a microtask continuation." When you see setTimeout, read it as "schedule a new task for later." Then ask: what is the queue depth on each? If the microtask queue can grow unboundedly between yields, you have a bug. If the macrotask queue is shallow, the page stays responsive.
Learn more
- ArticleJake Archibald: Tasks, microtasks, queues and schedulesJake Archibald
- Docs
- Article
- Docs