Skip to content

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

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

· · 10 min read
A person using a laptop showing code

Quick Take

'Microtasks run before macrotasks' is easy to recite and surprisingly hard to apply to five lines of mixed Promise and setTimeout code. I'd argue that tracing the output by hand, then checking it against a real run, teaches the order faster than any diagram does.

The JavaScript event loop is the mechanism that decides, after synchronous code finishes, which queued callback runs next, draining every microtask completely before it lets a single macrotask run. 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.

Quick take: 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 the code order, and a microtask that keeps queuing more microtasks can starve macrotasks indefinitely.

Why Does the Promise Log Before the setTimeout?

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'). According to MDN's execution model documentation, this ordering holds true across every major engine, V8, SpiderMonkey, and JavaScriptCore, because the microtask-drain behavior is specified by the ECMAScript job queue semantics, not left to individual browser implementations to decide differently from one another.

How Does await Affect Execution Order?

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 is syntax that suspends an async function's execution until a Promise settles, then resumes the remaining code as a microtask rather than running it synchronously in place, which is why the code after it still reads top-to-bottom even though it doesn't run that way. 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' in the trace above. Two details are worth internalizing here. The function body up to the first await runs synchronously, so 'a: fetching user' prints before 'end' even though the call looks asynchronous from the outside. And every subsequent await in the same function costs another trip through the microtask queue, which is why a loop with an await inside it schedules one microtask per iteration rather than one for the whole loop. Per MDN's documentation on async functions, this desugaring to .then() is specified behaviour rather than an engine detail, so the ordering holds identically in Node and every browser.

How Are Multiple Microtasks and Macrotasks Ordered?

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
QueueWhat lands in itDrains
MicrotaskPromise.then/catch/finally, code after await, queueMicrotask(), MutationObserverCompletely, before any macrotask runs
MacrotasksetTimeout, setInterval, requestIdleCallback, I/O and UI eventsOne task per turn, then the microtask queue drains again

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. It also explains why 2 timers registered with a 0 ms delay still can't run back to back if the first one queues a promise: that promise's continuation jumps ahead of the second timer, despite having been scheduled later.

A three-step way to verify the order for any snippet before trusting your mental model:

  1. List every scheduled callback in the order the synchronous code queues it, noting whether each is a microtask or a macrotask.
  2. Drain all microtasks first, in queue order, including any new ones a microtask itself schedules along the way.
  3. Run exactly one macrotask, then repeat step two before moving to the next macrotask in the queue.

The rule isn't one microtask, one macrotask, alternating. It's drain the entire microtask queue, then run exactly one macrotask, then drain again.

Share this Post on X Bluesky

What Does queueMicrotask() Do?

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. Per MDN's documentation for queueMicrotask(), the API was added specifically so library authors didn't have to fake a microtask with Promise.resolve().then(), a pattern that worked but obscured the intent and cost a small allocation for a Promise object nobody actually needed.

What Causes the Microtask 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. There's no console warning and no crash report, just a page that stops answering, which is what makes it hard to diagnose: nothing points at the line responsible.

Does Node.js Order These the Same Way?

For promises versus setTimeout, yes. Node adds two queues browsers don't have, though, and one of them changes order depending on the module format. I ran this file on Node 22.23.2, once saved as order.mjs and once as order.cjs:

setTimeout(() => console.log('timeout'), 0)
setImmediate(() => console.log('immediate'))
Promise.resolve().then(() => console.log('promise'))
process.nextTick(() => console.log('nextTick'))
console.log('sync')
$ node order.mjs | paste -sd' ' -
sync promise nextTick timeout immediate
$ node order.cjs | paste -sd' ' -
sync nextTick promise timeout immediate

Same code, different order. The Node.js process documentation explains it: Node drains the process.nextTick() queue and then the microtask queue, so in CommonJS nextTick wins, but an ES module is already being evaluated as part of the microtask queue, so promise callbacks get there first. The same page recommends queueMicrotask() over nextTick for most userland code. That advice looks better once you've seen the flip.

The second Node-only queue is setImmediate. Node's own event loop guide says the order of setTimeout(fn, 0) and setImmediate from the main module is non-deterministic, bound by process performance, while inside an I/O callback the immediate always runs first. I ran each case 200 times, spawning a fresh process for every run, and repeated the main-module case three times on an Apple M1. From the main module the winner wasn't stable: immediate came first 195, 199 and 193 times out of 200, and timeout took the rest. Inside an fs.readFile callback, immediate came first 200 of 200. So the docs are right that the main-module order isn't guaranteed, and a machine that mostly gives you one answer is exactly why code that depends on it passes review.

How Long Can You Block Before It Hurts?

MDN's PerformanceLongTaskTiming page defines a long task as one that occupies the UI thread for 50 milliseconds or more. Past that, input waits. The usual fix is chunking, and the queue you yield to decides whether chunking does anything at all. This splits 200 ms of busy work into twenty 10 ms chunks and measures how long a zero-delay timer waits:

function busy(ms) {
  const end = performance.now() + ms
  while (performance.now() < end) {}
}

async function run(label, pause) {
  const start = performance.now()
  let firedAt = 0
  setTimeout(() => {
    firedAt = performance.now() - start
  }, 0)
  for (let i = 0; i < 20; i++) {
    busy(10)
    await pause()
  }
  await new Promise((resolve) => setTimeout(resolve, 5))
  console.log(`${label}: timer waited ${firedAt.toFixed(1)} ms`)
}

await run('microtask yield', () => Promise.resolve())
await run('setImmediate yield', () => new Promise((resolve) => setImmediate(resolve)))

Five runs on Node 22.23.2: the microtask version made the timer wait 200.4 to 200.5 ms, the whole job. The setImmediate version: 20.2 to 20.3 ms. Chunking with await Promise.resolve() is chunking in name only. The timer landed after the second chunk rather than the first, most likely because an immediate runs in the check phase, after that iteration's timers have already been visited.

In the browser, the purpose-built tool is scheduler.yield(). MDN's compatibility data, checked September 29, 2026, lists Chrome 129 and Firefox 142, with no Safari support, so feature-detect it:

function yieldToMain() {
  if (globalThis.scheduler?.yield) {
    return scheduler.yield()
  }
  return new Promise((resolve) => setTimeout(resolve, 0))
}

Where Does This Show 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.

Flaky tests are the other place this surfaces, and they're worse because the failure rate looks random. A test that passes 9 runs out of 10 and fails on a loaded CI machine is almost always waiting on a fixed setTimeout where it should await a real promise. The tell is that raising the delay from 0 to 50 ms "fixes" it.

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.