Why the Event Loop Exists
JavaScript runs on a single thread. One call stack, one thing at a time. No locks needed, but how do you wait for a network response without freezing the page?
console.log("start");
const data = fetch("/api/data"); // if this blocked...
console.log(data); // ...nothing else could run
console.log("end");
If fetch blocked, every click and animation would freeze for the entire round trip. The event loop fixes this: the thread hands off slow work (timers, I/O) to the runtime, keeps executing, and picks up results when ready.
The Mental Model: One Stack, Two Queues
There's one call stack that must be empty before the event loop pulls anything new. Two queues feed it:
- Microtask queue: drains completely before the next macrotask.
- Macrotask queue: runs one task per loop iteration.
The rule: after the current script finishes and the stack is empty, drain the entire microtask queue (including any new microtasks added while draining), then render (if browser), then run exactly one macrotask.
Stage 1: The Call Stack and Blocking Code
No queues matter until the stack unwinds. A heavy synchronous loop freezes everything:
function blockFor(ms) {
const end = Date.now() + ms;
while (Date.now() < end) {} // busy-wait
}
setTimeout(() => console.log("timer fired"), 0);
blockFor(3000);
console.log("sync work done");
// Output: "sync work done" then "timer fired"
The timer can't run until the stack is empty, no matter its delay.
Stage 2: Web APIs and the Macrotask Queue
setTimeout registers the callback with the runtime's timer API and returns immediately. After the delay, the callback goes to the macrotask queue:
console.log("1");
setTimeout(() => console.log("2"), 0);
console.log("3");
// Output: 1, 3, 2
Stage 3: Promises and the Microtask Queue
Promise callbacks go to the microtask queue, which has higher priority:
console.log("1");
setTimeout(() => console.log("2 (macrotask)"), 0);
Promise.resolve().then(() => console.log("3 (microtask)"));
console.log("4");
// Output: 1, 4, 3, 2
Chained .then()s queue new microtasks while the queue is draining:
Promise.resolve()
.then(() => console.log("a"))
.then(() => console.log("b"))
.then(() => console.log("c"));
setTimeout(() => console.log("timer"), 0);
// Output: a, b, c, timer
Key: the microtask queue drains until empty, including any microtasks added during draining.
Stage 4: async/await is Sugar, Not Magic
async/await compiles to Promises and microtasks. await schedules the rest of the function as a microtask continuation:
async function run() {
console.log("A");
await null;
console.log("B");
}
console.log("start");
run();
console.log("end");
// Output: start, A, end, B
Performance consequence: awaiting sequentially in a loop serializes work:
// slow: each request waits for previous
for (const id of ids) {
const item = await fetchItem(id);
results.push(item);
}
// fast: all requests start immediately
const results = await Promise.all(ids.map(id => fetchItem(id)));
Stage 5: Rendering and Node's Extra Queues
In browsers, rendering happens between macrotasks (after microtasks drain). requestAnimationFrame is synchronized to the paint cycle, not an arbitrary timer.
Node.js subdivides macrotasks into phases (timers, pending callbacks, poll, check, close) and adds process.nextTick—a microtask-like queue that runs before Promise microtasks:
// Node.js only
Promise.resolve().then(() => console.log("promise microtask"));
process.nextTick(() => console.log("nextTick"));
// Output: nextTick, promise microtask
Edge Cases and Gotchas
- Microtask starvation: a microtask that keeps scheduling more microtasks can prevent timers, I/O, and rendering from ever running. Real production failure mode.
- Unhandled Promise rejections are silent by default: they fire an
unhandledrejectionevent asynchronously. Always catch. awaitinsideArray.prototype.forEachdoesn't wait:forEachdiscards the callback's return value. Usefor...offor sequential, orPromise.allfor concurrent.- Node timers aren't identical to browser's:
setImmediateruns in the "check" phase; ordering withsetTimeout(fn,0)depends on context.
Best Practices
- Use
queueMicrotaskfor scheduling work that must run before any timer or I/O. - Use
setTimeout(fn,0)for yielding to the browser's rendering cycle. - Use
requestAnimationFramefor visual updates. - Avoid
process.nextTickunless you absolutely need to bypass the queue—it's easy to starve I/O. - Always wrap
awaitintry/catchor attach.catch().
FAQ
Q: Why does setTimeout(fn,0) not run immediately?
A: It means "run as soon as the stack is empty and it's this task's turn." If the stack is busy, it waits.
Q: Why does a Promise callback run before a setTimeout callback, even if scheduled later? A: Promises go to the microtask queue, which drains completely before the macrotask queue is processed.
Q: Can a microtask queue prevent macrotasks from ever running? A: Yes—if a microtask keeps adding more microtasks, the macrotask queue never gets a turn. This is called microtask starvation.
Cheat Sheet
| Mechanism | Queue | Priority |
|---|---|---|
| Synchronous code | Call stack | Highest (runs first) |
Promise.then, queueMicrotask, await continuation | Microtask | Next |
setTimeout, setInterval, DOM events, I/O | Macrotask | One per loop iteration |
requestAnimationFrame | Render queue | Before next paint |
process.nextTick (Node) | Next tick queue | Before Promise microtasks |
Key Takeaways
- The event loop never interrupts running code.
- Microtasks drain completely before the next macrotask.
async/awaitis syntactic sugar over Promises and microtasks.- A busy call stack blocks everything—timers, promises, UI.
- Microtask starvation is a real bug; avoid recursive Promise chains without yielding.
Now go predict the output of any async code mix with confidence.

