The Event Loop’s Actual Ordering Guarantees
A resolved Promise doesn’t just usually beat setTimeout(fn, 0). The specification requires it to, every single time, for reasons that have nothing to do with which one you wrote first.
Run this exact snippet, in a browser or in Node, and it prints the same five lines in the same order every single time:
console.log('Start');
Promise.resolve().then(() => {
console.log('Promise 1');
Promise.resolve().then(() => console.log('Promise 2'));
});
setTimeout(() => console.log('Timeout'), 0);
console.log('End');
// Start, End, Promise 1, Promise 2, Timeout
Notice what’s strange about that output. “Promise 2” is scheduled from inside the callback that prints “Promise 1,” which runs after the synchronous code has already finished, and after setTimeout has already been handed to the browser. By any naive first-in-first-out intuition, “Timeout” should have a real shot at running before a promise that hasn’t even been created yet at the time setTimeout was called. It doesn’t get one. This isn’t a coincidence, a browser quirk, or a lucky race. It’s the specification working exactly as written.
Two Queues, Not One
The behavior above only makes sense once you stop thinking of “async callbacks” as a single queue and start thinking of them as two, with a hard rule about how they interleave. The ECMAScript specification calls the first one Jobs, what everyone colloquially calls microtasks: Promise reactions, queueMicrotask() callbacks, and MutationObserver callbacks all land here. The WHATWG HTML specification calls the second one Tasks, what everyone calls macrotasks: setTimeout, setInterval, UI events, and I/O completions all land here instead.
The rule that decides everything in the example above is this: after any task finishes and the call stack is empty, the event loop performs what the HTML spec calls a microtask checkpoint, and that checkpoint doesn’t just glance at the microtask queue once. It drains it completely, including any new microtasks that get added while it’s draining, before doing anything else at all, before running the next task, and before the browser is even allowed to paint a frame.
The Algorithm, Not the Metaphor
Stripped down to what actually happens, one full iteration of the browser’s event loop looks like this:
- Pop the oldest task off a task queue and run it, start to finish, on the call stack.
- The moment the call stack empties, perform a microtask checkpoint: run every job in the microtask queue, in FIFO order, including any jobs newly added by jobs that just ran, until the queue is genuinely empty.
- Only once the microtask queue is empty does the event loop decide whether to render, then move on to consider the next task.
Apply that to the snippet above. The entire top-level script is itself one task. Inside it, Promise.resolve().then(...) enqueues a microtask, and setTimeout hands its callback to the browser’s timer machinery, which will eventually place it on the task queue, a different queue entirely. “End” logs synchronously, because it’s still inside the currently running task. Only once that task finishes does step 2 fire: the microtask queue gets drained completely, “Promise 1” runs, it enqueues “Promise 2” as a new microtask, and the checkpoint keeps going specifically because the rule says “until empty,” not “once.” Only after both promise callbacks have run does the event loop even consider moving on to the next task, which is where “Timeout” was waiting the entire time.
Execution Order of the Example Above

Why a Resolved Promise Always Wins, Regardless of Order Written
Here’s the part that trips people up even after they’ve memorized “microtasks beat macrotasks”: the rule isn’t about which one you scheduled first in your source code. It’s about which queue a callback lands in, full stop. Schedule ten thousand chained .then() calls before a single setTimeout(fn, 0), and every one of those ten thousand microtasks runs before the timeout fires, because the checkpoint doesn’t stop until the microtask queue is empty, and a microtask that schedules another microtask keeps that queue non-empty. This is also precisely how you can accidentally starve the event loop entirely: a microtask that unconditionally re-schedules itself via queueMicrotask never lets the checkpoint finish, which means the browser never gets to run another task or repaint at all.
Where Node.js Quietly Adds a Third Queue
Node.js follows the same ECMAScript microtask rules as the browser for Promises, but it layers something extra on top that doesn’t exist in browser JavaScript at all: process.nextTick() has its own dedicated queue, and per Node’s own documentation, that nextTick queue is fully drained before the Promise microtask queue gets its turn, not merged into it. Run process.nextTick(() => console.log('bar')) alongside Promise.resolve().then(() => console.log('foo')) in a typical CommonJS script, and “bar” wins, every time, even though a Promise reaction is also technically a microtask.
This is exactly the kind of place developer intuition quietly breaks down, because the mental model “Promises are the fast lane” is close enough to right that people stop checking the actual spec text. It gets sharper still: that “nextTick always wins” guarantee specifically holds at CommonJS module boundaries and native-callback checkpoints, but it does not interrupt a microtask drain that’s already actively in progress, which is why the exact same two lines of code can print in the opposite order inside an ES module’s top-level evaluation, since ESM evaluation already runs through the microtask machinery before your code gets a chance to call process.nextTick at all. Precise ordering here is genuinely context-dependent in a way that “microtasks beat macrotasks” doesn’t prepare you for.
Where setTimeout(fn, 0) Quietly Isn’t 0
The other place intuition diverges from the algorithm is more subtle, and it doesn’t even require nested queues to explain: setTimeout(fn, 0) was never actually a promise of zero milliseconds. Browsers have clamped nested timer calls since long before the practice was standardized. The HTML specification codifies it directly: once a chain of nested setTimeout calls reaches a nesting depth of five, the browser is required to enforce a minimum delay, 4 milliseconds under the current spec text, even if your code explicitly requested 0.
Minimum Enforced setTimeout Delay by Nesting Depth

This clamp exists for a genuinely defensible reason: without it, a recursive setTimeout(fn, 0) loop could fire thousands of times per second, starving the rendering pipeline and any other pending task in exactly the way a runaway microtask chain can. The spec’s fix was to make the fifth and every subsequent nested call slightly slower on purpose, trading a small, deliberate delay for guaranteed responsiveness elsewhere.
Side by Side
| Queue | What lands here | Draining rule | Governing spec |
|---|---|---|---|
| Microtask queue | Promise reactions, queueMicrotask(), MutationObserver | Fully drained, including newly added jobs, before the next task | ECMAScript, Jobs and Job Queues |
| Task (macrotask) queue | setTimeout, setInterval, UI events, I/O completions | One task per event loop iteration | WHATWG HTML, event loop processing model |
| Node’s nextTick queue | process.nextTick() callbacks only | Fully drained before the Promise microtask queue, at CJS/native-callback checkpoints | Node.js documentation (no browser equivalent exists) |
Quick Checklist: Reasoning About Real Ordering
- Ask which queue a callback lands in, not which line scheduled it first; queue membership decides ordering, not source order.
- Remember the microtask checkpoint runs until the queue is empty, so a self-perpetuating chain of microtasks can starve every pending task indefinitely.
- In Node, don’t assume “Promise” and “microtask” are interchangeable; process.nextTick’s queue is separate and typically drains first at CJS boundaries.
- Never rely on setTimeout(fn, 0) actually meaning zero; past a nesting depth of five, the spec requires at least 4ms, regardless of what you passed in.
- When precise ordering matters across ESM and CJS, or across await boundaries, test the actual runtime rather than trusting a mental model built on the browser-only case.
What We Have Learned
“Microtasks run before macrotasks” is true, but it’s a summary of a more precise rule, not the rule itself: after every task, the event loop performs a microtask checkpoint that drains the entire microtask queue, including jobs the checkpoint itself creates along the way, before it will even consider running the next task or letting the browser paint. That’s why a resolved Promise’s callback beats a setTimeout(fn, 0) every time, regardless of which one appeared first in your source code, and it’s also why a self-scheduling microtask chain can freeze a page solid.
Node.js adds a genuine wrinkle browsers don’t have, a separate process.nextTick queue that usually, but not unconditionally, drains ahead of Promise reactions. And setTimeout(fn, 0) was never really zero to begin with; the HTML spec has required a 4ms floor on deeply nested timers since 2010, a deliberate trade that protects the rest of the page from a tight timer loop. None of this is exotic trivia. It’s the literal algorithm your runtime executes on every tick, and the gap between that algorithm and “async stuff happens eventually” is exactly where the confusing bugs live.

