Event Loop & Microtasks
โฑ๏ธ ~5-minute bite ยท solve the sandbox to master
5-Year-Old Metaphor
โ The physical, real-world picture. No jargon.๐ฝ๏ธ JavaScript is a restaurant with one chef โ but the order system is smarter than you think.
The restaurant analogy
The chef (call stack) can only cook one dish at a time. They work synchronously โ no multitasking, no interruptions while a dish is being prepared.
The head waiter (event loop) watches the kitchen constantly. The moment the chef finishes and the pass is clear (call stack empty), the head waiter decides what goes next.
VIP orders (microtask queue) โ Promise callbacks, queueMicrotask, MutationObserver. When the chef finishes a dish, ALL VIP orders are cooked before the next regular table is called. Even if a VIP order comes in while cooking another VIP order, it still gets priority.
Regular tables (macrotask queue) โ setTimeout, setInterval, I/O events, UI renders. Only ONE table is served per "turn" of the event loop. After that, VIP orders are checked again.
The 4 actors
๐Call Stack
Synchronous executor. LIFO. One thing at a time. While a frame is on the stack, nothing else can run.
๐Web APIs
Browser-provided off-thread workers: timers, fetch, DOM events. They complete and place callbacks in the macrotask queue.
โกMicrotask Queue
Holds Promise .then/catch/finally callbacks, queueMicrotask(), and MutationObserver. Drained COMPLETELY before every macrotask.
โฐMacrotask Queue
Holds setTimeout, setInterval, I/O, requestAnimationFrame (mostly). Only ONE task dequeued per event loop turn.
The drain rule โ the most important sentence
After every task (including the initial script execution), the event loop drains the entire microtask queue before picking the next macrotask. This means if a microtask enqueues another microtask, that new microtask also runs before any macrotask. Microtasks can starve macrotasks indefinitely.
ASCII: the event loop algorithm
| 1 | โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ |
| 2 | โ Run current script / task โ โ macrotask or initial script |
| 3 | โ (synchronous, call stack) โ |
| 4 | โโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโ |
| 5 | โ stack empty? |
| 6 | โผ |
| 7 | โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ |
| 8 | โ Drain ALL microtasks โ โ repeat until queue empty |
| 9 | โ (Promise .then, queueMicrotask) โ new microtasks get drained too |
| 10 | โโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโ |
| 11 | โ microtask queue empty? |
| 12 | โผ |
| 13 | โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ |
| 14 | โ [optional] Run requestAnimationFrameโ |
| 15 | โ callbacks & paint โ |
| 16 | โโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโ |
| 17 | โ |
| 18 | โผ |
| 19 | โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ |
| 20 | โ Dequeue ONE macrotask โ โ setTimeout, setInterval, I/O |
| 21 | โ (run it โ go back to top) โ |
| 22 | โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ |
Interactive Sandbox
โ Move something, see it react instantly.Pattern
Code
| 1 | setTimeout(() => console.log('A'), 0); |
| 2 | Promise.resolve().then(() => console.log('B')); |
๐Call Stack
โกMicrotask Queue
empty
โฐMacrotask Queue
empty
๐ Console output
(nothing logged yet)
Challenge
Step through all 5 patterns to the final step in each. Predict the output before advancing โ that's the real test.
Why Should I Care?
โ The exact interview question + the bug it kills.Exact interview questions
Q: Why does setTimeout(fn, 0) run after Promise.resolve().then(fn)?
Because setTimeout schedules a macrotask, and Promise .then schedules a microtask. The event loop always drains the entire microtask queue before dequeuing the next macrotask. Even with a 0ms delay, setTimeout cannot bypass this ordering guarantee.
| 1 | setTimeout(() => console.log('macro'), 0); |
| 2 | Promise.resolve().then(() => console.log('micro')); |
| 3 | // Output: micro โ macro |
Q: What is microtask starvation?
If microtasks continuously enqueue new microtasks, the macrotask queue โ including the UI rendering pipeline โ never gets a turn. The browser appears frozen.
| 1 | // โ DANGER โ this locks the UI indefinitely |
| 2 | function spin() { |
| 3 | queueMicrotask(spin); // enqueues itself forever |
| 4 | } |
| 5 | spin(); |
| 6 | // Browser: completely unresponsive. |
| 7 | // requestAnimationFrame never fires. |
| 8 | // Click handlers never run. |
Fix: use setTimeout(fn, 0) or requestAnimationFrame for long-running deferred loops โ they yield to the macrotask queue and allow renders between iterations.
Q: What happens on unhandled promise rejection?
The browser fires an unhandledrejection event on window (Node.js fires unhandledRejection on process). This happens at the end of a microtask checkpoint, after the current task completes โ not immediately when the reject() call is made.
| 1 | window.addEventListener('unhandledrejection', (e) => { |
| 2 | console.error('Uncaught promise rejection:', e.reason); |
| 3 | e.preventDefault(); // suppress default browser error |
| 4 | }); |
| 5 | ย |
| 6 | Promise.reject(new Error('oops')); |
| 7 | // No .catch() attached โ unhandledrejection fires |
Production bugs caused by misunderstanding the event loop
Bug 1: React setState batching and microtask flushing (React 18)
React 18 introduced automatic batching โ multiple setState calls inside a single event handler are batched into one render. But setState calls made inside microtasks (e.g., inside a .then() callback or await) were NOT batched in React 17, causing extra renders. React 18 fixed this โ but code relying on the old behavior (expecting a re-render between two awaits) can break.
| 1 | // React 18: BOTH setState calls are batched โ 1 render |
| 2 | async function handleClick() { |
| 3 | setCount(c => c + 1); // batched |
| 4 | await fetch('/api/...'); |
| 5 | setLoading(false); // also batched in React 18 |
| 6 | } |
| 7 | // React 17: 2 renders (the await breaks batching) |
Bug 2: Long microtask chains blocking paint
Chaining hundreds of .then() calls or awaiting in a loop blocks the browser from painting between iterations. The UI freezes for the entire chain duration. Use requestAnimationFrame to yield to the render cycle, or scheduler.postTask() (Chrome) for priority-based scheduling.
| 1 | // โ Blocks paint โ all microtasks before next frame |
| 2 | async function processBatch(items) { |
| 3 | for (const item of items) { |
| 4 | await process(item); // 1000 awaits = no paint for whole loop |
| 5 | } |
| 6 | } |
| 7 | ย |
| 8 | // โ Yields to render between chunks |
| 9 | async function processBatch(items) { |
| 10 | for (const item of items) { |
| 11 | await new Promise(r => requestAnimationFrame(r)); |
| 12 | process(item); // browser can paint between items |
| 13 | } |
| 14 | } |
The Deep Dive
โ Spec refs, engine internals, the minutiae.HTML spec ยง8.1.7 โ the event loop algorithm
The authoritative definition lives in the WHATWG HTML Living Standard ยง8.1.7. Key clauses:
- Each event loop has a task queue (macrotasks) and a microtask queue. The microtask queue is not a task queue in the spec's terminology โ it is separate and drained differently.
- After each task, the spec says: "perform a microtask checkpoint." This means run microtasks until the queue is empty, then continue.
- The spec defines "perform a microtask checkpoint" as running microtasks until the microtask queue is empty โ including any microtasks newly added during that drain.
Node.js vs browser: libuv phases
Node.js uses libuv for its event loop, which has multiple distinct phases โ unlike the browser's single macrotask queue:
| 1 | Node.js event loop phases (per libuv): |
| 2 | โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ |
| 3 | โ timers (setTimeout, setInterval) โ |
| 4 | โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค |
| 5 | โ pending callbacks (I/O errors from prev) โ |
| 6 | โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค |
| 7 | โ idle, prepare (internal) โ |
| 8 | โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค |
| 9 | โ poll (I/O callbacks) โ |
| 10 | โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค |
| 11 | โ check (setImmediate) โ |
| 12 | โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค |
| 13 | โ close callbacks (socket close, etc.) โ |
| 14 | โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ |
| 15 | Between EACH phase: microtask checkpoint runs |
setImmediate (Node-only) runs in the check phase, after I/O callbacks but before timers on the next iteration. In I/O callbacks it reliably runs before setTimeout(fn, 0). Outside I/O callbacks, the order is non-deterministic.
Node also has process.nextTick() โ it runs before the microtask queue (before Promise callbacks). It's even higher priority than microtasks and is not part of the event loop per se.
queueMicrotask vs Promise.resolve().then
Both schedule a callback as a microtask in the same queue. The differences are minor but worth knowing:
queueMicrotask(fn)
- Direct, no Promise overhead
- Callback receives no arguments
- Errors throw as uncaught (no .catch())
- Slightly more performant
- Available globally (browser + Node โฅ11)
Promise.resolve().then(fn)
- Creates a Promise object (GC overhead)
- Callback receives resolved value
- Errors handled via .catch() chain
- Composable โ chain more .then()
- Works in all environments
MutationObserver microtask timing
MutationObserver callbacks are delivered as microtasks โ they run after the current task's synchronous code and microtask checkpoint, but before the next paint. This makes them useful for reacting to DOM changes synchronously within the same event loop turn:
| 1 | const observer = new MutationObserver((mutations) => { |
| 2 | // Runs as a microtask โ before next paint |
| 3 | console.log('DOM changed:', mutations.length); |
| 4 | }); |
| 5 | observer.observe(document.body, { childList: true }); |
| 6 | ย |
| 7 | document.body.appendChild(document.createElement('div')); |
| 8 | // Synchronous append โ queues microtask callback |
| 9 | // Callback fires before browser repaints |
requestAnimationFrame โ where it sits in the loop
rAF callbacks are not microtasks and not macrotasks in the traditional sense. They are called by the browser before each paint, after all microtasks and after the current macrotask completes. The spec calls this the "update the rendering" step โ it only runs when the browser decides to repaint (typically ~60 fps, but throttled in background tabs).
| 1 | // Execution order: |
| 2 | setTimeout(() => console.log('macro')); // macrotask |
| 3 | queueMicrotask(() => console.log('micro')); // microtask |
| 4 | requestAnimationFrame(() => console.log('raf')); // pre-paint |
| 5 | ย |
| 6 | // Output order: |
| 7 | // micro (drained immediately after script) |
| 8 | // raf (before paint, which is before next macrotask tick) |
| 9 | // macro |
| 10 | // Note: raf vs macro order is browser-dependent when no pending paint |
Edge cases table
| Scenario | Output / behavior |
|---|---|
| Nested setTimeout(fn, 0) inside setTimeout(fn, 0) | Inner fires in the NEXT event loop turn โ not the same turn as the outer. Each setTimeout is a separate macrotask. |
| async IIFE ordering: (async () => { await x; console.log('A'); })(); console.log('B') | B logs before A. The IIFE runs synchronously up to await, then suspends. B is synchronous and runs first. |
| Promise.resolve().then(a).then(b) vs two separate chains | In a chain: a runs, then b is enqueued. In two separate chains: both first .then()s run before either second .then(). |
| setTimeout inside a Promise .then() | The timer callback becomes a macrotask and will run after all current microtasks โ potentially many turns later. |
| Node.js: process.nextTick vs Promise.resolve().then | nextTick fires BEFORE Promise callbacks โ it has higher priority than the microtask queue. |
Interview Questions
โ Real questions from real interviews โ with answers.C โ B โ A. Sync first, then microtasks, then macrotasks.
Microtasks continuously enqueue new microtasks, blocking the macrotask queue (and paint) forever.
A โ B โ C. The async function runs synchronously to the first `await`, then suspends; the caller continues; C resumes as a microtask.
Neither โ rAF runs in the browser's 'update the rendering' step, between macrotask execution and the next macrotask pick.
`process.nextTick` fires before Promise microtasks โ it's higher priority than the microtask queue and not part of the libuv phases.
Memory Game
โ Quick quiz โ lock the concept in long-term memory.What is the output order of: `setTimeout(A, 0); Promise.resolve().then(B); console.log(C)`?