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:
- Pop one macrotask from the queue, run to completion (no other JS interleaves).
- Drain the microtask queue — run all queued microtasks (and any they enqueue), until empty.
- If a render is due, run
requestAnimationFramecallbacks → style/layout/paint → rendering steps. - If idle time available, run any
requestIdleCallbackcallbacks. - 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 first →
3, 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:
- Sync code completes.
- Microtasks drain (Promise.then).
- Next tick: macrotask (setTimeout). But before paint, the browser may also do rAF.
- Just before paint: rAF callbacks fire.
- 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
requestAnimationFrameor paint. setImmediateis “after I/O” (next iteration’scheckphase);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 untilawait b()→1.b()runs sync untilawait Promise.resolve()→3.bsuspends, control returns toa’s caller.asuspends, control returns to main →5.- Microtask queue: resolve
Promise.resolve()→ continueb→4. - Microtask queue:
breturns → continuea→2.
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 microtask —Promise.resolve().then(log)doesn’t log immediately.MutationObservercallbacks are microtasks —el.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.unhandledrejectionfires as a macrotask after a Promise rejects with no handler. Different timing fromunhandled 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 theawait 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.nextTickoutranking promise microtasks. Different details, same shape.”
Cross-references
- Concurrent rendering (uses event-loop slicing): ../05_react/concurrent_rendering.md
- INP and long tasks: ../15_performance/01_core_web_vitals.md
- Rendering pipeline (rAF connects to this): 02_rendering_pipeline_deep.md
- Web Workers (separate event loop per worker): 06_web_workers.md
Further reading
- Jake Archibald — “Tasks, microtasks, queues and schedules”: https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/
- MDN — Event loop: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Event_loop
- Scheduler API (
scheduler.yield): https://developer.chrome.com/blog/introducing-scheduler-yield - Node.js event loop: https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick