The Event Loop — Macrotasks, Microtasks, Rendering

4 min read source

The Event Loop — Macrotasks, Microtasks, Rendering

TL;DR

JavaScript runs on a single thread with one call stack. Async work is scheduled onto queues that the event loop drains in a fixed order: run one macrotask to completion → drain the entire microtask queue → (in browsers) run rendering steps → next macrotask. Microtasks (Promise.then, queueMicrotask, await continuations, MutationObserver) always run before the next macrotask (setTimeout, MessageChannel, I/O, DOM events). Getting the ordering right — and knowing microtasks can starve rendering — is the classic senior probe.

Interview Q&A

Q: Walk me through one “tick” of the event loop.

A:

  1. Pull one task off the macrotask queue and run it to completion (the stack must empty).
  2. Drain the whole microtask queue — and any microtasks those microtasks schedule, until it’s empty.
  3. (Browser only) Run the render pipeline if it’s time to paint: requestAnimationFrame callbacks → style → layout → paint.
  4. Go back to step 1.

The key asymmetry: one macrotask per tick, but the microtask queue is drained completely before yielding.

Q: What’s the output?

A:

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

1 and 4 are synchronous. 3 is a microtask — runs after the current synchronous run finishes but before any macrotask. 2 is a macrotask — runs last. setTimeout(…, 0) does not mean “now”; it means “after the current task and all microtasks.”

Q: Which callbacks are microtasks vs macrotasks?

A:

Microtasks (drained fully each tick) Macrotasks (one per tick)
Promise.then/catch/finally setTimeout / setInterval
await continuations setImmediate (Node)
queueMicrotask(fn) MessageChannel / postMessage
MutationObserver DOM events, I/O, fetch resolution dispatch

requestAnimationFrame is neither — it runs in the render step, after microtasks, before paint.

Q: How does await fit the model?

A: await x suspends the async function and schedules its continuation as a microtask when x settles. Everything after an await is effectively a .then() callback.

async function f() {
  console.log("a");
  await null;          // suspend; resume as a microtask
  console.log("b");
}
f();
console.log("c");
// a, c, b

Q: What is microtask starvation?

A: Because the loop drains the entire microtask queue before rendering or running the next macrotask, a microtask that keeps scheduling more microtasks blocks paint and timers forever:

function loop() { queueMicrotask(loop); }
loop(); // freezes the tab — rendering never gets a turn

A setTimeout-based loop would not freeze rendering, because each iteration is a separate macrotask with render steps in between. Rule: use microtasks for “finish this logical unit of work now,” not for ongoing scheduling.

Q: How does Node’s event loop differ from the browser’s?

A: Node runs phases (timers → pending → poll → check → close), and the microtask queue is drained between each phase, not just once per loop. Two Node-specific extras:

  • process.nextTick() runs before the Promise microtask queue — its own higher-priority queue. Overusing it can starve I/O.
  • setImmediate() runs in the check phase, setTimeout(…, 0) in the timers phase; their relative order is only guaranteed when both are scheduled inside an I/O callback (then setImmediate wins).

Q: How do you yield to the event loop to keep the UI responsive?

A: Break long work into macrotasks so rendering and input can interleave:

async function chunkedWork(items) {
  for (const [i, item] of items.entries()) {
    process(item);
    if (i % 100 === 0) await new Promise(r => setTimeout(r));  // yield
  }
}

Modern API: await scheduler.yield() (where supported) yields but resumes with priority. This is the lever behind good INP — see ../15_performance/01_core_web_vitals.md.

Gotchas / edge cases

  • setTimeout(…, 0) is not 0ms — the HTML spec clamps nested timeouts to a minimum (≈4ms after 5 levels of nesting). For “run after microtasks” use queueMicrotask; for “run as a fresh macrotask ASAP” MessageChannel beats setTimeout(0).
  • await adds at least one microtask hop even when the awaited value is already resolved — await null still defers.
  • Promise.resolve().then() vs queueMicrotask() schedule onto the same queue; queueMicrotask just skips creating a throwaway promise.
  • Layout reads after writes force sync reflow — that’s a rendering cost inside a task, not an event-loop ordering issue, but it shows up in the same profiler trace. See ../15_performance/.
  • requestAnimationFrame fires before paint, not after — schedule visual updates there; reading layout inside it is already past style recalc for the previous frame.

What a senior is expected to say

  • “One macrotask per tick, then the microtask queue drains completely, then render. setTimeout(0) is ‘next macrotask,’ not ‘now.’”
  • await continuations are microtasks — code after await runs before the next setTimeout.”
  • “Microtasks can starve rendering; long-running scheduling should use macrotasks or scheduler.yield.”
  • “Node adds phases and process.nextTick, which runs ahead of promises.”

Cross-references

Further reading