frontend / browser internals / 01_event_loop.md

The Event Loop — Macrotasks, Microtasks, requestAnimationFrame, Idle

6 min read source

The Event Loop — Macrotasks, Microtasks, requestAnimationFrame, Idle

TL;DR

The event loop is the engine that schedules JavaScript work. It has one stack, a macrotask queue, a microtask queue, and special phases (animation frames, idle). The senior rule: between any two macrotasks, the engine drains the entire microtask queue. That’s why Promise.then runs before setTimeout(fn, 0) even when both are queued from the same handler — they live in different queues with different priorities.

Interview Q&A

Q: One-paragraph mental model.

A: JS is single-threaded. The event loop repeats this cycle:

  1. Pop one macrotask from the queue, run to completion (no other JS interleaves).
  2. Drain the microtask queue — run all queued microtasks (and any they enqueue), until empty.
  3. If a render is due, run requestAnimationFrame callbacksstyle/layout/paintrendering steps.
  4. If idle time available, run any requestIdleCallback callbacks.
  5. Loop.

The single biggest “huh?” moment: microtasks always finish before the next macrotask. If a microtask keeps queueing more microtasks, the next macrotask never runs.

Q: Macrotask vs microtask — examples?

A:

Macrotask sources Microtask sources
setTimeout, setInterval Promise.then/catch/finally
setImmediate (Node) queueMicrotask()
I/O callbacks (Node) MutationObserver callback
MessageChannel port.onmessage process.nextTick (Node, higher priority than promises)
requestAnimationFrame-adjacent events
User events (click, keydown)

Q: Show the canonical ordering example.

A:

console.log("1");

setTimeout(() => console.log("2"), 0);

Promise.resolve().then(() => console.log("3"));

queueMicrotask(() => console.log("4"));

console.log("5");

Output: 1, 5, 3, 4, 2.

Why:

  • 1, 5 — synchronous, run immediately.
  • 2 — queued as macrotask (setTimeout).
  • 3, 4 — queued as microtasks.
  • After the synchronous block ends, microtasks drain first3, 4.
  • Then the next macrotask → 2.

Note: 3 runs before 4 because Promise.resolve().then queues before the queueMicrotask call (microtasks run in FIFO).

Q: What about requestAnimationFrame?

A: rAF callbacks run in a specific phase just before paint — not the macrotask queue. The browser tries to fire them at the display refresh rate (~60 Hz / every 16.7ms, or 120 Hz on high-refresh screens).

requestAnimationFrame(() => console.log("paint!"));

If you queue rAF + setTimeout(0) + Promise.then in the same task, the order is:

  1. Sync code completes.
  2. Microtasks drain (Promise.then).
  3. Next tick: macrotask (setTimeout). But before paint, the browser may also do rAF.
  4. Just before paint: rAF callbacks fire.
  5. Paint.

In practice: rAF fires once per frame, regardless of how many setTimeout(0) you queued. setTimeouts can fire faster than 60Hz; rAFs can’t.

Q: When use rAF vs setTimeout(fn, 0) vs queueMicrotask?

A:

  • rAF — animations, anything visual. Synced to frame rate; the browser pauses your rAF when the tab is hidden.
  • setTimeout(fn, 0) — defer work to the next macrotask. Min delay is throttled to ~4ms in nested timeouts; tabs that are backgrounded throttle to seconds.
  • queueMicrotask(fn) — defer to “after the current sync block but before any other macrotask.” Almost always: do you really need this, or would a Promise.then chain work?

For “let the browser paint then run my code,” use setTimeout or rAF. For “run after the current promise chain settles,” use queueMicrotask (rare in app code).

Q: requestIdleCallback — when?

A: Runs only when the browser is idle (no rAF work pending, no input being processed). Use for non-essential work:

requestIdleCallback((deadline) => {
  while (deadline.timeRemaining() > 0 && tasks.length) {
    runOne(tasks.shift());
  }
  if (tasks.length) requestIdleCallback(/* continue */);
});

deadline.timeRemaining() tells you how many ms you have before the browser wants the main thread back. Yield by re-queuing.

Use cases: analytics flush, prefetching, log uploads — anything the user doesn’t see and doesn’t care about being immediate.

Caveats: not available in Safari (still). Polyfill via setTimeout. Don’t rely on it for required work.

Q: scheduler.yield and scheduler.postTask — the modern primitives.

A: Scheduler API (Chrome 94+, growing support) — explicit priorities:

// scheduler.yield — pause briefly to let the browser do other work
async function processItems(items) {
  for (const item of items) {
    processOne(item);
    await scheduler.yield();      // explicit yield point
  }
}

// scheduler.postTask — schedule with priority
scheduler.postTask(work, { priority: "user-blocking" });   // highest
scheduler.postTask(work, { priority: "user-visible" });    // default
scheduler.postTask(work, { priority: "background" });      // lowest

scheduler.yield() is the modern replacement for await new Promise(r => setTimeout(r, 0)) — explicit “give the browser a chance to process input/render.”

Useful for INP — break long tasks at strategic points.

Q: How does the event loop affect React 18 concurrent rendering?

A: React’s scheduler runs work in time-sliced chunks (~5ms). Between chunks it awaits a microtask + checks if the browser has higher-priority work pending; if so, it yields.

This is why a long render in React 18+ doesn’t lock the UI — the renderer is cooperatively yielding to the event loop’s other work (input events, paint).

See useTransition / useDeferredValue in ../05_react/concurrent_rendering.md.

Q: Node event loop vs browser — what’s different?

A:

  • Node has 6 phases in its event loop: timers, pending callbacks, idle/prepare, poll (I/O), check (setImmediate), close callbacks. Each phase has its own queue.
  • process.nextTick() (Node-only) runs before microtasks — higher priority than Promise.then.
  • No requestAnimationFrame or paint.
  • setImmediate is “after I/O” (next iteration’s check phase); setTimeout(fn, 0) is “after this timer phase.”

Same concept (loop processing queues), different details. App code is usually the same; libraries that integrate at the I/O layer must care.

Q: Microtask starvation — what is it?

A: A microtask that queues another microtask in an infinite loop:

function spin() {
  queueMicrotask(spin);
}
spin();    // event loop never advances — UI freezes, no input handled

The microtask queue drains before the next macrotask, so this loop never lets the browser do anything else. Similar to a while(true); recognizing the pattern matters for debugging “the page is frozen” bugs.

Q: Synchronous vs async order — show me a subtle case.

A:

async function a() {
  console.log("1");
  await b();
  console.log("2");
}
async function b() {
  console.log("3");
  await Promise.resolve();
  console.log("4");
}

a();
console.log("5");

Output: 1, 3, 5, 4, 2.

Why:

  • a() runs sync until await b()1.
  • b() runs sync until await Promise.resolve()3.
  • b suspends, control returns to a’s caller.
  • a suspends, control returns to main → 5.
  • Microtask queue: resolve Promise.resolve() → continue b4.
  • Microtask queue: b returns → continue a2.

The mental model: await splits a function into pieces; each piece schedules the next on the microtask queue.

Gotchas / edge cases

  • setTimeout(fn, 0) is not actually 0ms — minimum ~4ms in nested calls, throttled to 1s+ in backgrounded tabs.
  • Promise.then(fn) runs synchronously up to the resolve point, then the callback is a microtaskPromise.resolve().then(log) doesn’t log immediately.
  • MutationObserver callbacks are microtasksel.appendChild(x) then an observer fires after the current sync block, before next macrotask.
  • Backgrounded tabs throttle macrotasks aggressively (clamped to 1s+); rAF is paused. Don’t rely on timers for keep-alive work in hidden tabs.
  • SharedArrayBuffer + Atomics.wait — the only way to actually block JS execution synchronously (in workers). Use sparingly; legitimate cases are rare.
  • unhandledrejection fires as a macrotask after a Promise rejects with no handler. Different timing from unhandled exception.

What a senior is expected to say

  • “Single thread, one macrotask at a time. Between macrotasks, the entire microtask queue drains. That’s why Promise.then beats setTimeout(0).”
  • “rAF runs once per frame, just before paint. Use it for animations and visual work, not for general ‘run later.’”
  • “Microtask starvation: a microtask that queues more microtasks freezes the loop. Recognizable bug pattern.”
  • “Modern scheduler API (scheduler.yield, scheduler.postTask) is the explicit replacement for the await setTimeout(0) hack — breaks long tasks at INP-friendly points.”
  • “Backgrounded tabs throttle setTimeout/setInterval; rAF pauses. Don’t rely on timers for keep-alive in hidden tabs.”
  • “Node’s loop has phases (timers, I/O poll, check) + process.nextTick outranking promise microtasks. Different details, same shape.”

Cross-references

Further reading