Microtask vs Macrotask: The Two Queues That Decide JavaScript Order
Why a promise resolves before setTimeout(0), how the microtask queue drains between tasks, and the loop that freezes a page.
Table of contents
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('promise'));
console.log('sync');This prints sync, promise, timeout. The setTimeout was scheduled first and asked for zero delay, and it still runs last.
The reason is that these two callbacks go into different queues with different draining rules.
Key takeaways#
- Macrotasks (tasks) are the outer unit of work: one script evaluation, one timer callback, one I/O event. The event loop runs one per iteration.
- Microtasks are promise reactions,
queueMicrotask, andMutationObservercallbacks. The queue is drained completely after each macrotask. - Microtasks queued by microtasks are drained in the same pass, which is why an infinite microtask loop freezes the page permanently.
- Rendering happens between macrotasks, never in the middle of a microtask drain.
awaitis a promise reaction. Everything after it is a microtask.
The two queues#
A macrotask is one discrete unit of work the event loop picks up. Evaluating a script, a setTimeout or setInterval callback, a DOM event handler, an I/O completion, a MessageChannel message — each of these is a task.
A microtask is a callback scheduled to run at the end of the current task, before anything else gets a turn. Promise .then/.catch/.finally reactions, the continuation after an await, queueMicrotask(), and MutationObserver callbacks all land here.
The loop, simplified to what matters:
- Take one macrotask from the task queue and run it to completion.
- Drain the entire microtask queue — including microtasks added while draining.
- Render, if the browser decides it is time.
- Go to 1.
Step two is the whole story. The microtask queue is not sampled or time-sliced; it runs until it is empty.
Why does a promise beat setTimeout(0)?#
Because the synchronous script is itself a macrotask, and the microtask queue is drained before the next macrotask is picked up. The setTimeout callback is sitting in the task queue behind that drain.
Walking the opening example:
- The script starts running — this is macrotask one.
setTimeoutschedules its callback in the task queue.Promise.resolve().thenschedules its callback in the microtask queue.console.log('sync')runs immediately.- The script ends. The task is complete.
- The microtask queue drains —
promiseprints. - The next macrotask runs —
timeoutprints.
The zero in setTimeout(fn, 0) was never the point. Even at zero, the callback is a task, and one full microtask drain stands between it and the current moment. Browsers also clamp nested timeouts to a minimum of 4ms after five levels of nesting, but that is a separate detail — the ordering here holds at any delay.
The loop that freezes the page#
Because microtasks queued during a drain are added to the same drain, a microtask that always schedules another one never lets the loop reach step three:
function spin() {
Promise.resolve().then(spin);
}
spin(); // The tab stops responding. Permanently.There is no rendering, no user input handling, no timer callbacks — the loop never gets past step two. The tab is not slow; it is stuck.
The same shape written with setTimeout is merely wasteful:
function spin() {
setTimeout(spin, 0);
}
spin(); // Burns CPU, but the page still renders and responds.Each iteration is a separate macrotask, so rendering and input get their turn in between. This is the practical difference between the two queues, and it is why recursive work belongs in a task rather than a microtask.
await is a microtask#
await is promise reaction syntax, so everything after it in the function body is a microtask continuation:
async function run() {
console.log('a');
await null;
console.log('c');
}
run();
console.log('b');This prints a, b, c. The body runs synchronously up to the await; the rest is scheduled as a microtask and runs after the current script finishes.
await null still yields, even though there is no promise to wait for — the value is wrapped and the continuation is queued regardless. Awaiting something already resolved does not skip the queue.
Ordering across both queues#
A worked example combining them:
console.log('1');
setTimeout(() => {
console.log('2');
Promise.resolve().then(() => console.log('3'));
}, 0);
Promise.resolve().then(() => {
console.log('4');
setTimeout(() => console.log('5'), 0);
});
console.log('6');Output: 1, 6, 4, 2, 3, 5.
1and6are synchronous, in the first task.4is a microtask, drained at the end of that task. It queues a timer for5.2is the first timer's task. Inside it,3is queued as a microtask.3drains immediately after that task ends — before the next timer.5is the last task in the queue.
The pattern to internalise: after every single macrotask, every pending microtask runs.
Node.js differs#
The browser model above is specified by HTML. Node.js has the same microtask concept but organises macrotasks into phases — timers, pending callbacks, poll, check, close — and adds two things browsers do not have.
process.nextTick() has its own queue that drains before promise microtasks, which makes it higher priority than a promise reaction. And setImmediate() runs in the check phase, after poll, which gives it different ordering relative to setTimeout(fn, 0) — an ordering that is genuinely nondeterministic when both are called from the main module, because it depends on how long the process took to start.
Code that relies on the exact interleaving of setTimeout(fn, 0) and setImmediate() at startup is relying on timing, not on a rule.
Where this actually bites#
- A "responsive" loop that is not. Chunking work through promise callbacks keeps everything in one microtask drain, so nothing paints.
- Reading layout after a state change. A framework flushing DOM updates in a microtask means your
.thenmay run before the browser has laid anything out.requestAnimationFrameis the correct hook for post-layout reads. - Assuming
setTimeout(fn, 0)is immediate. It is one full microtask drain away at best, and behind every other pending task besides.
For the surrounding machinery — the call stack, the phases, and where rendering fits — see the JavaScript event loop explained.
Tags
- JavaScript
- Async
- Event Loop
- Performance