Skip to content

JavaScript Event Loop: Microtasks vs Macrotasks in 2026

Why Your Promise Runs Before Your setTimeout(fn, 0)

A code-first look at the JavaScript event loop, showing the actual execution order between promises, setTimeout, and queueMicrotask, not just the theory.

· · 5 min read

Quick Take

I could recite 'microtasks run before macrotasks' for years without being able to predict the actual output of five lines of mixed Promise and setTimeout code. Writing the code out and tracing it fixed that faster than any diagram did.

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 means Promise.resolve().then(fn) always runs before setTimeout(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.

Frequently Asked Questions

What's the difference between a microtask and a macrotask?
A microtask (a resolved Promise's .then() callback, or a queueMicrotask() callback) runs immediately after the current synchronous code finishes, and the entire microtask queue is drained completely before the event loop moves on to anything else. A macrotask (a setTimeout callback, a UI event handler, an I/O callback) runs one at a time per event loop iteration, with the full microtask queue drained again between each individual macrotask.
Why does setTimeout(fn, 0) not run immediately?
setTimeout(fn, 0) schedules fn as a macrotask, which only runs after the current synchronous code finishes AND the entire microtask queue is empty. Even a 0ms delay means 'run this on a future event loop iteration,' not 'run this right now.' Any Promise .then() callback queued before the setTimeout call will always run first, regardless of the 0ms delay, because it's a microtask, not a macrotask.
Can a microtask loop starve the event loop entirely?
Yes. Because the microtask queue must be fully drained before the event loop proceeds to the next macrotask, a microtask that queues another microtask, which queues another, and so on indefinitely, will run forever and block macrotasks (including rendering and UI event handling) from ever running. This is a real, if uncommon, bug: a recursive .then() chain or queueMicrotask() call with no termination condition can freeze a page exactly like an infinite synchronous loop would.