HAMZA KAREEM / CONCEPTS hamzawi.dev

Lesson 381 min read

The Event Loop: Microtasks vs Macrotasks

Web developmentweb development / JavaScript

Debug-along. Guess the output before reading on:

console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");

Most people say: 1, 2, 3, 4 — "setTimeout(0) runs next, right?"

Actual output: 1, 4, 3, 2.

🧠 Why. JavaScript runs one call stack. When it empties, the event loop decides what runs next — and there are TWO queues, not one:

  • Microtask queue — Promise .then/.catch/.finally, await continuations, queueMicrotask.
  • Macrotask queue — setTimeout, setInterval, I/O, UI events.

The rule practitioners get wrong: after each macrotask (and after the initial script), the loop DRAINS the entire microtask queue before touching the next macrotask.

So in the trace:

1️⃣ "1" and "4" are synchronous → print now.

2️⃣ Script ends → drain microtasks → "3".

3️⃣ Only now the next macrotask → "2".

setTimeout(…, 0) does NOT mean "as soon as possible." It means "after the current macrotask AND all pending microtasks."

⚠️ The trap that bites in production: microtasks can starve the loop. A .then that schedules another .then, forever, will freeze the page — timers, rendering, and clicks never get a turn:

function loop() { Promise.resolve().then(loop); }

loop(); // UI frozen, but CPU busy

A setTimeout-based loop would NOT freeze the UI, because each iteration is a separate macrotask, so rendering and events run in between.

await is just microtasks in disguise:

async function f() {
  console.log("a");
  await null;          // pause here
  console.log("b");    // resumes as a microtask
}
f();
console.log("c");
// Output: a, c, b

Everything after the first await is queued as a microtask — that's why "c" beats "b".

🎯 Mental model: think "flush all microtasks between every macrotask." Node.js follows the same two-queue rule (with extra phases + process.nextTick, which jumps ahead of even Promise microtasks).

Your exercise: predict the exact output, then run it:

console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve().then(() => {
  console.log("C");
  setTimeout(() => console.log("D"), 0);
});
queueMicrotask(() => console.log("E"));
console.log("F");

Which lines print before the first setTimeout callback fires, and why does D print last?