Tasks and Microtasks
Date: 2026-08-16
Two queues with different priorities. Microtasks drain completely after every single task, before the browser is allowed to render — which is why promise callbacks jump ahead of timers, and why a self-queueing microtask can freeze a page that a loop of timers wouldn’t.
What they are
- A task (sometimes “macrotask”) — a discrete unit of work from the task queue: a timer callback, a DOM event handler, a network response, a parsed chunk of HTML
- A microtask — a smaller unit with higher priority: a promise callback (
.then,awaitcontinuation — Promises and Async Await),queueMicrotask(), aMutationObservercallback
The rule
1 take ONE task, run it to completion
2 drain the ENTIRE microtask queue
(including microtasks queued by other microtasks)
3 render, if it's time for a frame
4 back to 1
One task, then all microtasks. Not one microtask — all of them, including any queued during the draining.
Worked
console.log('1');
setTimeout(() => console.log('2'), 0); // task
Promise.resolve().then(() => console.log('3')); // microtask
queueMicrotask(() => console.log('4')); // microtask
console.log('5');1 synchronous
5 synchronous
3 microtask — drains before the next task
4 microtask
2 task — runs last, despite the 0ms delay
The setTimeout with zero delay runs after both promise callbacks, because the entire microtask queue is drained before the loop takes another task.
In plain terms: promises are queued in the fast lane. Timers are queued in the slow lane. Everything in the fast lane goes before anything else in the slow lane, no matter what delay you asked for.
Why this design
Microtasks exist so that a sequence of promise resolutions completes as one logical unit, without the DOM being rendered or a click handler firing halfway through. It guarantees a consistent view of the world across a chain of .thens.
That guarantee is also the hazard.
The microtask starvation trap
// LOCKS THE PAGE COMPLETELY
function spin() { Promise.resolve().then(spin); }
spin();Each microtask queues another, and step 2 says drain the entire queue. It never empties, so the loop never reaches rendering. The page is frozen and cannot be recovered.
Compare:
// SLOW BUT SURVIVABLE
function spin() { setTimeout(spin, 0); }
spin();This queues a task, so the loop completes step 2, renders at step 3, and comes back. The page stays interactive, if busy.
The difference matters practically: recursive promise chains are a natural way to write polling or batched processing, and they’re the version that can hang the tab.
Where it bites
awaitin a loop creates a microtask per iteration. Ten thousand iterations drains as one uninterrupted block, so nothing renders in the middle — the page appears frozen even though nothing is technically blockingMutationObservercallbacks are microtasks. A callback that mutates the DOM can trigger itself, and the same starvation applies- Splitting work with promises doesn’t yield to rendering.
await Promise.resolve()between chunks feels like yielding and isn’t — the browser still can’t paint. To genuinely yield you need a task:setTimeout(…, 0),scheduler.yield()where available, orrequestAnimationFramefor visual work. See Long Tasks and Blocking
Rendering sits between tasks
Worth stating explicitly because it explains a common bug:
el.style.opacity = '0';
doExpensiveWorkFor500ms();
el.style.opacity = '1';
// the user never sees opacity 0 — no frame was rendered in betweenStyle changes don’t appear when you set them. They appear when the browser next renders, which is after the current task and all microtasks. If you need a paint in between, you need to end the task — see The Rendering Pipeline and The Event Loop.