Every JavaScript developer eventually hits the same confusing moment: a setTimeout(fn, 0) runs after a promise that was created afterward. If you’ve ever stared at console output that seemed to defy the order your code was written in, the explanation lives in one place — the event loop, and specifically how it distinguishes microtasks from macrotasks. Understanding this distinction isn’t academic trivia; it directly explains bugs in UI rendering, race conditions in async code, and why some “quick fixes” with setTimeout only mask deeper timing issues.
JavaScript Is Single-Threaded, But Not Synchronous-Only
JavaScript runs on a single thread: one call stack, one thing executing at a time. That’s easy to accept. What trips people up is that the language still supports non-blocking behavior — network requests, timers, and promises don’t freeze the page while they wait. The event loop is the mechanism that makes this possible. It doesn’t execute your async code in parallel; it just decides when queued callbacks get a turn on that single thread once the current synchronous code finishes.
There are two separate queues feeding the event loop, and the order in which it drains them is the whole story:
- Macrotask queue (also called the “task queue”):
setTimeout,setInterval, I/O callbacks, UI events,postMessage. - Microtask queue: Promise callbacks (
.then,.catch,.finally),async/awaitcontinuations, andqueueMicrotask.
The Rule That Explains Everything
After each macrotask finishes executing, the event loop does not immediately grab the next macrotask. It first drains the entire microtask queue — every microtask, including new ones added while draining — before it’s allowed to move on. Only once the microtask queue is completely empty does the loop proceed to the next macrotask.
This is why a promise resolved “instantly” always wins a race against a timer, even a zero-millisecond one:
console.log("1: script start");
setTimeout(() => {
console.log("2: setTimeout callback");
}, 0);
Promise.resolve().then(() => {
console.log("3: promise callback");
});
console.log("4: script end");
// Output:
// 1: script start
// 4: script end
// 3: promise callback
// 2: setTimeout callback
The synchronous lines (1 and 4) run first because nothing yields control until the script finishes. Then the microtask queue is drained — that’s the promise callback (3). Only after it’s empty does the engine pull the timer callback (2) off the macrotask queue, even though it was scheduled first.
async/await Is Just Promises With Better Syntax
async functions don’t introduce a new scheduling mechanism — every await is sugar for a .then() continuation, which means it schedules a microtask. This trips people up inside loops:
async function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function run() {
console.log("A");
await delay(0);
console.log("B");
}
run();
console.log("C");
// Output: A, C, B
The function runs synchronously up to the first await (printing “A”), then yields control entirely back to the caller — “C” logs before the function resumes. Because delay uses setTimeout internally, resuming “B” requires waiting for a macrotask, not just a microtask, so it lands after everything else scheduled in between.
Where This Bites in Real Code
1. Starving the event loop with microtasks
Because the microtask queue must be fully drained before any macrotask (including rendering in browsers) can run, code that recursively schedules new microtasks can block the UI indefinitely:
function recurse() {
Promise.resolve().then(recurse);
}
recurse(); // Starves rendering and timers — the tab appears frozen
If you need to break work into chunks without blocking the UI, use setTimeout or requestAnimationFrame for the yielding step, not another microtask.
2. Assuming state updates are visible “immediately” after an await
In UI frameworks, a value set right before an await and read right after can be stale if another handler (a macrotask, like a click event) ran in between and mutated shared state. If a function suspends at await, treat everything after it as running in a fresh scheduling context — re-check assumptions about shared state rather than trusting that nothing changed while you were “waiting.”
3. Debugging “my callback ran too late”
If a setTimeout(fn, 0) seems delayed, check whether something is flooding the microtask queue first — a chain of unresolved promise handlers will always take priority, no matter how small the timer delay is.
A Mental Model You Can Rely On
- Run the current synchronous code to completion.
- Drain the microtask queue completely — promise callbacks,
async/awaitresumptions,queueMicrotask— including any new microtasks queued during this drain. - Pull exactly one macrotask off the queue (a timer, an I/O callback, a UI event) and run it to completion.
- Go back to step 2.
Every ordering question in async JavaScript reduces to walking through these four steps. It’s a small model, but it resolves nearly every “why did this run before that” surprise you’ll encounter.
Conclusion
The event loop isn’t a black box — it’s a simple, deterministic priority system: synchronous code first, then all pending microtasks, then one macrotask, repeat. Once that ordering is internalized, promise chains, async/await, and timer-based code stop feeling unpredictable. The next time output order looks wrong, ask which queue each callback belongs to before assuming there’s a bug.