The rule "microtasks run before macrotasks" is easy to state and hard to apply to real code until you've traced through an example with output that surprised you at least once. Here's the trace that made it click for me, five lines that produce an order almost nobody predicts correctly on the first try.
TL;DR: After the current synchronous code block finishes, the JavaScript engine drains the entire microtask queue (Promise callbacks,
queueMicrotask) before running even one macrotask (setTimeout, I/O, UI events). This meansPromise.resolve().then(fn)always runs beforesetTimeout(fn, 0), no matter how the code is ordered, and a microtask that keeps queuing more microtasks can starve macrotasks indefinitely.
The Trace
console.log('1: sync start');
setTimeout(() => console.log('2: setTimeout'), 0);
Promise.resolve().then(() => console.log('3: promise then'));
console.log('4: sync end');
// Output:
// 1: sync start
// 4: sync end
// 3: promise then
// 2: setTimeout
Walking through it: console.log('1') and the setTimeout/Promise.resolve().then() calls themselves all run synchronously, in order, so '1' logs immediately, setTimeout schedules its callback as a macrotask (even at 0ms delay), .then() schedules its callback as a microtask, and console.log('4') logs before either callback runs. Once the synchronous block finishes, the engine checks the microtask queue first, finds the .then() callback, runs it ('3'), then checks again (empty now), then finally moves to the next macrotask, the setTimeout callback ('2').
Why This Matters in Practice
async function loadUserDashboard(userId) {
console.log('a: fetching user');
const user = await fetchUser(userId); // suspends here, resumes as a microtask
console.log('b: user loaded', user.name);
return user;
}
console.log('start');
loadUserDashboard(42);
console.log('end');
// Output order:
// start
// a: fetching user
// end
// b: user loaded ... (after fetchUser's promise resolves, as a microtask)
await doesn't block anything, it's syntactic sugar over .then(), so everything after the await runs as a microtask once the awaited promise resolves. This is why 'end' logs before 'b: user loaded', the async function's execution past the await is scheduled as a microtask, not run synchronously in place, even though the code reads top-to-bottom like it would run in order.
Ordering Multiple Microtasks and Macrotasks Together
setTimeout(() => console.log('macrotask 1'), 0);
setTimeout(() => console.log('macrotask 2'), 0);
Promise.resolve().then(() => console.log('microtask 1'));
Promise.resolve().then(() => console.log('microtask 2'));
// Output:
// microtask 1
// microtask 2
// macrotask 1
// macrotask 2
Both microtasks run before either macrotask, not interleaved. The rule isn't "one microtask, one macrotask, alternating," it's "drain the entire microtask queue, then run exactly one macrotask, then drain the microtask queue again (including any new microtasks that macrotask itself queued), then the next macrotask." That full-drain behavior is what surprises people who expect a simpler round-robin.
queueMicrotask() for Explicit Microtask Scheduling
function processInBatches(items, batchSize, processItem) {
let index = 0;
function processNextBatch() {
const end = Math.min(index + batchSize, items.length);
for (; index < end; index++) {
processItem(items[index]);
}
if (index < items.length) {
queueMicrotask(processNextBatch); // yields to microtask queue between batches
}
}
processNextBatch();
}
queueMicrotask() is the explicit, non-Promise API for scheduling a microtask directly, useful when you want to break up synchronous work without the ceremony of wrapping it in a Promise chain. It still runs before any macrotask, so this pattern doesn't yield to rendering or UI events between batches, for that, you'd want setTimeout(fn, 0) or requestIdleCallback instead, which are actual macrotasks that let the browser paint and handle input between chunks.
The Starvation Bug
function microtaskLoop() {
queueMicrotask(microtaskLoop); // never terminates
}
microtaskLoop();
setTimeout(() => console.log('this never runs'), 0);
Because the microtask queue must fully drain before any macrotask runs, and microtaskLoop keeps re-queuing itself indefinitely, the setTimeout callback, and anything else waiting as a macrotask, including UI rendering and click handlers, never gets a turn. This is a genuine footgun with recursive .then() chains that don't have a clear termination condition, it produces a frozen page with no error message, just a JavaScript engine perpetually working through a queue that never empties.
Where This Shows Up in Real Debugging
The most common place this actually bites in production code isn't a contrived timing example, it's a state update that a developer assumed would be visible immediately after an await, checked in a setTimeout "just to be safe," and found the timing worked purely by coincidence. Relying on a setTimeout to "wait until the promise settles" is fragile precisely because macrotask timing isn't guaranteed relative to how many microtasks happen to be queued ahead of it, the safer fix is always to await the specific promise you actually care about, not to insert an arbitrary delay and hope the ordering lines up the same way in production as it did in a quick local test.
Conclusion
The full microtask-queue-drain-before-next-macrotask rule explains every ordering surprise in mixed async code: why a promise beats a zero-delay setTimeout, why code after an await doesn't run synchronously even though it reads that way, and why a runaway .then() chain can freeze a page without a single synchronous infinite loop in sight. Trace a few examples like the ones above by hand; the rule sticks a lot better after tracing than after reading a description of it.