Microtasks vs. Macrotasks
Microtasks, like promise callbacks, always run before the next macrotask, like a setTimeout, even a zero-delay one.
Loading visual…
Definition
The event loop pulls work from two different queues. The macrotask queue, sometimes called the task or callback queue, holds things like setTimeout and setInterval callbacks, UI events, and I/O callbacks. The microtask queue holds promise callbacks, `.then`, `.catch`, `.finally`, and jobs scheduled with `queueMicrotask`. The two queues are not treated equally. After the currently running script finishes and the call stack empties, the event loop drains the microtask queue completely, running every microtask currently in it, plus any new ones those microtasks add along the way, before it is allowed to touch the macrotask queue. Only when the microtask queue is fully empty does the loop take a single macrotask, run it, and then drain microtasks again before considering another macrotask. This is why `Promise.resolve().then(fn)` reliably runs before `setTimeout(fn, 0)`, even though both were scheduled at essentially the same moment: the promise callback goes into the higher-priority microtask queue, while the timer callback has to wait in the macrotask queue behind every pending microtask.
Examples
setTimeout(() => console.log("timeout"), 0);
Promise.resolve().then(() => console.log("promise"));
// logs: promise, timeoutBoth are scheduled back to back, but the promise callback is a microtask and always drains before the timer callback, a macrotask, gets its turn.
queueMicrotask(() => console.log("micro"));
setTimeout(() => console.log("macro"), 0);
console.log("sync");
// logs: sync, micro, macroThe synchronous log runs first. Then the microtask queue drains (micro), and only after it is empty does the event loop take the setTimeout callback (macro).
Common mistakes
- Assuming setTimeout(fn, 0) fires before a promise scheduled at the same moment; the timer callback is a macrotask and always waits behind every pending microtask.
- Forgetting that a microtask can schedule another microtask, and the event loop keeps draining until the queue is truly empty before it looks at a macrotask.
- Mixing up which APIs land in which queue, like thinking queueMicrotask behaves like setTimeout(fn, 0) when it is actually a microtask.
Key takeaways
- Microtasks include promise callbacks (.then, .catch, .finally) and queueMicrotask.
- Macrotasks include setTimeout, setInterval, and most I/O and UI event callbacks.
- The entire microtask queue drains, including newly added microtasks, before a single macrotask runs.
- That is why a promise callback always runs before a setTimeout(fn, 0) scheduled around the same time.
setTimeout(() => console.log("timeout"), 0); Promise.resolve().then(() => console.log("promise")); What is the logged order?
Where you see this
- Predicting whether a setTimeout(fn, 0) will run before or after a chain of .then() callbacks.
- Using queueMicrotask to defer work until right after the current synchronous code, but before any timers or rendering.
- Debugging why UI updates queued in a promise chain appear before a scheduled timer fires.
- Understanding why a runaway chain of microtasks can delay timers and I/O from ever getting a turn.