Two callbacks registered one after the other do not necessarily run in that order. A promise callback goes ahead of a timer callback, even when written after it.
The reason is a separate queue, served first by the event loop, whose existence explains most of the surprising execution orders in JavaScript.
Definition
A microtask is a short piece of work placed in a queue that is drained in full the moment the call stack empties, before any ordinary task is picked up. Three things produce one.
- A then(), a catch or a finally on a promise that is already settled or has just settled.
- The resumption after an await, which is the very same mechanism written differently.
- A call to
queueMicrotask, the explicit way to register one.
setTimeout(() => console.log("task"), 0);
queueMicrotask(() => console.log("microtask"));
Promise.resolve().then(() => console.log("promise"));
console.log("synchronous");
// synchronous
// microtask
// promise
// taskMicrotask and task: the difference that matters
| Criterion | Microtask | Task |
|---|---|---|
| Origin | Promises, queueMicrotask | Timers, events, requests |
| Count per pass | The whole queue, uninterrupted | One only, then the loop comes round |
| Page rendering | None until the queue is empty | Possible between two tasks |
Those three rows explain the behavior you observe: the microtask queue is drained as one block, and a microtask that registers another one lengthens the same pass instead of waiting for the next.
Starving the loop
That priority comes at a price. A microtask registering itself with no stopping condition occupies the queue forever: timers stop firing, clicks stop being handled, and the page freezes without a single infinite loop showing in the code.
// Do not run this: the event loop never gets control back
// function loop() { queueMicrotask(loop); }
// loop();
// The correct version, which lets the loop breathe
function loopProperly(left) {
if (left === 0) return console.log("done");
setTimeout(() => loopProperly(left - 1), 0);
}
loopProperly(3);A recursion written with await has the same defect when nothing hands control back: every resumption is a microtask. Slipping in a real wait, even of one millisecond, is enough to go through the task queue again and unblock the interface.
Frequently asked questions
Why prefer queueMicrotask over setTimeout(fn, 0)?
Because a microtask runs before any rendering and before any other task, which guarantees no intermediate state is ever visible. setTimeout, by contrast, lets the page repaint in between, which is sometimes exactly what you are after.
Is the order between two microtasks guaranteed?
Yes, the queue is strictly first in, first out. Two then calls placed on already-fulfilled promises run in the order they were registered. That guarantee is what makes promise behavior reproducible from one run to the next.
Do you need this detail to write correct code?
Not day to day, but it becomes indispensable the moment an unexpected order shows up, in tests above all: an assertion written right after an asynchronous call runs before that call resumes. Understanding the queue then explains the gap in seconds.