Microtasks & Macrotasks
The event loop juggles two queues: macrotasks (also called tasks) and microtasks. Knowing which API queues into which — and the order they drain in — is the key to predicting when a callback actually runs. It is also the difference between code that feels snappy and code that drops frames.
The two queues at a glance
Each turn of the event loop picks one macrotask, runs it, then drains every microtask before doing anything else. Microtasks are higher priority and run sooner.
Macrotasks — one per loop turn. The next macrotask only starts after all microtasks have drained.
Microtasks — drained completely between macrotasks. New microtasks queued during the drain run in the same drain.
Which API uses which queue?
Microtask —
Promise.then,Promise.catch,Promise.finally, the resumption afterawait,queueMicrotask,MutationObservercallbacks.Macrotask —
setTimeout,setInterval, I/O callbacks, UI events (click, keydown),postMessage,MessageChannel,setImmediatein Node.Animation frame —
requestAnimationFramecallbacks run between macrotasks and the next paint, separate from both queues.
Promise vs setTimeout
setTimeout(() => console.log("timeout"), 0);
Promise.resolve().then(() => console.log("promise"));
console.log("sync");sync promise timeout
Even though both are queued with no delay, the promise wins. The synchronous code runs first, then microtasks drain (promise), then the next macrotask runs (timeout).
setTimeout vs setInterval
Both are macrotask APIs. setTimeout(fn, ms) queues one task after at least ms milliseconds. setInterval(fn, ms) queues a fresh task every ms milliseconds, regardless of whether previous callbacks finished.
// setInterval can pile up if the callback is slow.
const id = setInterval(() => {
console.log("tick", Date.now());
}, 100);
// Stop it later
setTimeout(() => clearInterval(id), 500);Recursive setTimeout — self-spacing
function tick() {
console.log("tick");
setTimeout(tick, 100); // schedule next only after this one finishes
}
tick();requestAnimationFrame
requestAnimationFrame(cb) is for visual updates. The browser runs the callback once per frame, right before the next paint — usually about every 16 ms on a 60 Hz display. It is not a microtask and not a regular macrotask; it lives between them and the render step.
function animate(time) {
// 'time' is a high-resolution timestamp in ms
el.style.transform = "translateX(" + (time / 10) + "px)";
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);Drops to ~30 fps automatically when the device is busy — safer than
setInterval(animate, 16).Pauses entirely in background tabs, saving battery.
Aligned with the paint, so visual changes look smooth.
queueMicrotask
When you need a microtask without the overhead of creating a promise, use queueMicrotask. It schedules a function to run after the current task but before any other macrotask.
console.log("a");
queueMicrotask(() => console.log("b"));
Promise.resolve().then(() => console.log("c"));
setTimeout(() => console.log("d"), 0);
console.log("e");a e b c d
Microtasks drain fully — sometimes too fully
If a microtask queues another microtask, the new one runs in the same drain. This is great for chained promises, but it can starve rendering if a chain never ends.
Promise.resolve()
.then(() => console.log("then 1"))
.then(() => console.log("then 2"))
.then(() => console.log("then 3"));
setTimeout(() => console.log("timeout"), 0);then 1 then 2 then 3 timeout
All three then callbacks run before the timeout because they keep adding to the microtask queue while the drain is in progress.
Cheat-sheet: priority order
- Synchronous code currently on the call stack.
- Microtasks — drained fully after every macrotask.
- requestAnimationFrame callbacks — once per frame, before paint.
- Browser render (style, layout, paint).
- The next macrotask —
setTimeout, I/O, UI events…
- The next macrotask —