The Event Loop — Macrotasks, Microtasks, Rendering

Updated 4 min read source
On this page6
  1. TL;DR
  2. In depth
  3. Gotchas / edge cases
  4. What a senior is expected to say
  5. Cross-references
  6. Further reading

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.

In depth

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

  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.

What’s the output?

js
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.”

Which callbacks are microtasks vs macrotasks?

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.

How does await fit the model?

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.

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

What is microtask starvation?

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:

js
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.

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

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).

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

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

js
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 Core Web Vitals — LCP, INP, CLS.

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 Frontend Performance — Senior Interview Prep.
  • 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 4

  • “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