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 unhandledrejection event asynchronously. Always catch.
  • await inside Array.prototype.forEach doesn't wait: forEach discards the callback's return value. Use for...of for sequential, or Promise.all for concurrent.
  • Node timers aren't identical to browser's: setImmediate runs in the "check" phase; ordering with setTimeout(fn,0) depends on context.

Best Practices

  • Use queueMicrotask for scheduling work that must run before any timer or I/O.
  • Use setTimeout(fn,0) for yielding to the browser's rendering cycle.
  • Use requestAnimationFrame for visual updates.
  • Avoid process.nextTick unless you absolutely need to bypass the queue—it's easy to starve I/O.
  • Always wrap await in try/catch or 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

MechanismQueuePriority
Synchronous codeCall stackHighest (runs first)
Promise.then, queueMicrotask, await continuationMicrotaskNext
setTimeout, setInterval, DOM events, I/OMacrotaskOne per loop iteration
requestAnimationFrameRender queueBefore next paint
process.nextTick (Node)Next tick queueBefore Promise microtasks

Key Takeaways

  • The event loop never interrupts running code.
  • Microtasks drain completely before the next macrotask.
  • async/await is 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.