The Event Loop, Microtasks, and Macrotasks — Explained Properly
Why a setTimeout(fn, 0) doesn't run immediately, why a Promise callback jumps the queue ahead of it, and what's actually happening between the two.
Given the call stack is single-threaded (previous post), how does JavaScript handle a network request, a timer, or a click that hasn't happened yet without freezing? The answer is the event loop — and the classic interview question that exposes whether someone actually understands it is this exact snippet.
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// Output: 1, 4, 3, 2 — not 1, 2, 3, 4The three pieces
- The call stack — runs synchronous code, one frame at a time, to completion.
- The microtask queue — Promise `.then`/`.catch`/`.finally` callbacks and `queueMicrotask` land here.
- The macrotask (task) queue — `setTimeout`, `setInterval`, and I/O callbacks land here.
The rule that explains the output above
The engine runs all synchronous code first (`1` and `4` print immediately — the stack has to fully empty before anything else runs). Once the stack is empty, it drains the entire microtask queue before touching the macrotask queue — so the Promise's `3` prints before the timer's `2`, even though the timer was scheduled first and even with a `0` delay. `setTimeout(fn, 0)` has never meant "run now" — it means "run after the stack clears and every pending microtask has run."
Why this actually matters in real code
This is the mechanism behind bugs where a UI update inside a `.then()` appears to "jump the queue" ahead of a timer-based animation frame, or where two async operations that look like they should race actually resolve in a fixed, predictable order. Once you know microtasks always fully drain before the next macrotask, that ordering stops being mysterious and becomes something you can reason about — and predict — before running the code.
The snippets above are the short version — the full, runnable code lives on GitHub.