The Event Loop — Macrotasks, Microtasks, requestAnimationFrame, Idle
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.
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
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.
Related
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.mozilla.org/en-US/docs/Web/API/Scheduler/yield - Node.js event loop: https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick
Interview angle 6
- “How does the event loop schedule work?” - one macrotask runs to
completion, then the entire microtask queue drains, then the browser may
render. That ordering is why a
Promise.thencallback runs before asetTimeout(fn, 0)queued earlier - the timeout is a macrotask waiting behind every pending microtask. - “Where does
requestAnimationFramefit?” - once per frame, immediately before paint. That makes it the right place for animation and for DOM writes you want batched with the frame, and the wrong tool for “run this later” in general. - “What is microtask starvation?” - a microtask that queues another microtask, indefinitely. The queue never empties, so the loop never reaches rendering or the next macrotask and the page freezes with no error. It is a recognisable bug pattern rather than a theoretical one.
- “How do you break up a long task properly?” -
scheduler.yield(), which yields to pending input and then resumes ahead of other queued work. The oldawait new Promise(r => setTimeout(r, 0))trick yields but sends your continuation to the back of the queue, so a busy page starves the work you were trying to finish. - “What happens to timers in a background tab?” -
setTimeoutandsetIntervalare throttled heavily, andrequestAnimationFramestops entirely. Anything relying on a timer for a keep-alive or a poll silently stops working when the user switches tabs. - “Does Node work the same way?” - the same microtask rule, but the macrotask
side is divided into phases - timers, poll, check - and
process.nextTickruns ahead of promise callbacks. Code that assumes browser ordering can reorder subtly when moved to Node.