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.
In depth
One-paragraph mental model.
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.
Macrotask vs microtask — examples?
| 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) |
Show the canonical ordering example.
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).
What about requestAnimationFrame?
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.
When use rAF vs setTimeout(fn, 0) vs queueMicrotask?
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).
requestIdleCallback — when?
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.
scheduler.yield and scheduler.postTask — the modern primitives.
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);
// explicit yield point
await scheduler.yield();
}
}
// scheduler.postTask — schedule with priority
scheduler.postTask(work, { priority: "user-blocking" }); // highest
scheduler.postTask(work, { priority: "user-visible" }); // default
scheduler.postTask(work, { priority: "background" }); // lowestscheduler.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.
How does the event loop affect React 18 concurrent rendering?
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 Concurrent Rendering — useTransition, useDeferredValue, How Priorities Work.
Node event loop vs browser — what’s different?
- 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.
Microtask starvation — what is it?
A microtask that queues another microtask in an infinite loop:
function spin() {
queueMicrotask(spin);
}
spin(); // event loop never advances — UI freezes, no input handledThe 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.
Synchronous vs async order — show me a subtle case.
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 6
- “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): Concurrent Rendering — useTransition, useDeferredValue, How Priorities Work
- INP and long tasks: Core Web Vitals — LCP, INP, CLS
- Rendering pipeline (rAF connects to this): Rendering Pipeline Deep Dive
- Web Workers (separate event loop per worker): Web Workers, SharedWorker, BroadcastChannel
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