Frontend / JavaScript core / 10_event_loop.md

The Event Loop — Macrotasks, Microtasks, Rendering

Updated 4 interview angles 5 min read source
On this page11
  1. Walk me through one “tick” of the event loop.
  2. What’s the output?
  3. Which callbacks are microtasks vs macrotasks?
  4. How does await fit the model?
  5. What is microtask starvation?
  6. How does Node’s event loop differ from the browser’s?
  7. How do you yield to the event loop to keep the UI responsive?
  8. Gotchas
  9. Related
  10. Further reading
  11. Interview angle

The Event Loop — Macrotasks, Microtasks, Rendering

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.

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.

Gotchas

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

Further reading

Interview angle 4

  • “Walk me through one turn of the event loop.” - one macrotask runs to completion, then the microtask queue drains entirely, then the browser may render. That ordering is the whole model, and it is why setTimeout(fn, 0) means “after the current task and all its microtasks”, not “now”.
  • “Where does the code after an await run?” - in a microtask. So it runs before any pending setTimeout, even one queued earlier, because the whole microtask queue drains before the next macrotask is picked up.
    • Follow-up: “Can that cause a problem?” - yes. A microtask that queues another microtask can starve the loop indefinitely, and rendering never happens because the queue never empties. Long runs of work belong in macrotasks or behind scheduler.yield().
  • “Why does a long task make the page feel broken?” - rendering and input handling only get a turn between macrotasks, so a 300ms task means 300ms with no paint and no response to a click. Splitting the work is the fix; making it faster is often not enough on a slow device.
  • “Does Node behave the same way?” - the same microtask rule, but the macrotask side is split into phases - timers, poll, check - and it adds process.nextTick, which runs before promise callbacks. Code that assumes browser ordering can reorder subtly when it moves to Node.