intermediate

How the JavaScript Event Loop Works: Microtasks vs Macrotasks

Understand the JavaScript event loop for interviews — call stack, microtask and macrotask queues, and how to predict the output of setTimeout vs Promise questions.


Why interviewers ask about the event loop

Every senior JavaScript interview eventually gets here, because the event loop explains everything else: why setTimeout(fn, 0) doesn't run immediately, why a promise callback beats a timer, why the UI freezes during a long loop, and why async/await doesn't make code parallel. Interviewers rarely ask "explain the event loop" cold — they hand you a snippet and ask "what does this print?" If you can reason through ordering, you understand the runtime.

The mental model

JavaScript executes on a single thread with three moving parts:

  1. The call stack — where function frames execute, one at a time.
  2. Task queues — where callbacks wait:
    • the microtask queue: promise reactions (.then, await continuations), queueMicrotask, MutationObserver.
    • the macrotask queue (a.k.a. task queue): setTimeout, setInterval, I/O, message events, user input.
  3. The event loop — a scheduler that repeats one rule forever:

Run one macrotask to completion → drain the entire microtask queue → (browser may render) → take the next macrotask.

The critical detail candidates miss: microtasks are drained completely between macrotasks, and microtasks queued during the drain also run before the next macrotask.

The canonical interview question

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

Output: 1, 4, 3, 2.

Reasoning, step by step:

  • 1 and 4 are synchronous — they run as part of the current macrotask (the script itself).
  • The .then callback goes to the microtask queue.
  • The timer callback goes to the macrotask queue.
  • When the script finishes, the microtask queue is drained first (3), and only then does the event loop pick up the next macrotask (2).

The harder variant: interleaving

setTimeout(() => console.log("timeout 1"), 0);
 
Promise.resolve()
  .then(() => {
    console.log("promise 1");
    return Promise.resolve();
  })
  .then(() => console.log("promise 2"));
 
setTimeout(() => console.log("timeout 2"), 0);
 
queueMicrotask(() => console.log("microtask"));

Output: promise 1, microtask, promise 2, timeout 1, timeout 2.

Every promise reaction is a separate microtask, so chained .then calls don't run back-to-back if other microtasks are already queued. Both timers wait until the microtask queue is completely empty.

Where async/await fits

await is syntax over promises: everything after an await becomes a microtask continuation.

async function main() {
  console.log("A");
  await null;          // yields — the rest is queued as a microtask
  console.log("B");
}
 
main();
console.log("C");
// A, C, B

This is why await inside a loop serializes work, and why two async functions don't run "in parallel" — they interleave at await points on one thread. If an interviewer asks how to run tasks concurrently, the answer is to start the promises first and await them together — see N async tasks in parallel and Promise.all.

Starving the event loop

Because microtasks drain completely, an infinite microtask chain blocks rendering and timers forever:

function loop() {
  Promise.resolve().then(loop); // never yields to macrotasks — page freezes
}

Swap Promise.resolve().then for setTimeout(loop, 0) and the page stays responsive: macrotasks yield between iterations, letting the browser render. This distinction — microtasks for ordering guarantees, macrotasks for yielding — is the senior-level takeaway interviewers listen for.

Node.js differences worth mentioning

In Node.js the macrotask side is split into phases (timers → I/O → setImmediate → close callbacks), and process.nextTick runs before promise microtasks. If the interview is Node-flavored, mention:

  • process.nextTick > promise microtasks > timers.
  • setImmediate runs after I/O callbacks; setTimeout(fn, 0) ordering versus setImmediate is only deterministic inside an I/O callback.

Common interview follow-ups

  • "Why does setTimeout(fn, 0) still delay?" — the callback must wait for the current macrotask plus all microtasks; browsers also clamp nested timers (~4 ms after 5 levels).
  • "How would you break up a CPU-heavy task?" — chunk it with setTimeout/requestIdleCallback, move it to a Web Worker, or use scheduler.yield() where supported.
  • "What's the microtask trap in MutationObserver or React state updates?" — batched reactions run before paint, so reading layout between microtasks can force sync reflow.

The event loop is the theory behind several implementation questions in this catalog: custom setTimeout, custom setInterval, debounce, and throttle all exist because of how task scheduling works.


  • Closures

    beginner

    What a closure really is, why loops with var trip people up, and how closures power memoization, once(), and module patterns.

  • The this Keyword

    intermediate

    The four binding rules, arrow-function behavior, and the lost-this bugs that call, apply, and bind exist to fix.

  • The prototype chain, __proto__ vs prototype, and what class syntax actually does under the hood.