Event Loop — Microtasks vs Macrotasks
Understand how JavaScript executes asynchronous code with a single thread. Master the event loop, task queues, and execution order to debug tricky async behavior and ace interview questions.
Table of Contents
Overview
JavaScript is single-threaded — it has one call stack and can execute one piece of code at a time. Yet it handles network requests, timers, and user interactions without freezing. The secret is the event loop.
The event loop continuously checks if the call stack is empty, then picks tasks from queues to execute. There are two types of queues: microtasks (Promises, queueMicrotask) which run immediately after the current code, and macrotasks (setTimeout, setInterval) which run one at a time between microtask flushes.
Understanding the difference between these queues is essential for predicting execution order, debugging async bugs, and answering one of the most common frontend interview topics.
Why this matters
"What's the output of this code?" questions with Promises and setTimeout are asked in nearly every frontend interview. The event loop is the mental model you need to answer them correctly every time.
JavaScript Execution Model
Before diving into the event loop, you need to understand the three pieces that make async JavaScript work together.
Call Stack
A LIFO stack where JavaScript executes functions one at a time. When a function is called, it's pushed on. When it returns, it's popped off.
Web APIs
Browser-provided APIs (setTimeout, fetch, DOM events) that run outside the call stack. They handle async work and push callbacks to queues when done.
Task Queues
Holding areas for callbacks waiting to run. The event loop moves tasks from queues to the call stack when it's empty.
console.log("1: Start"); // → Call stack: runs immediately setTimeout(() => { console.log("2: Timeout"); // → Web API handles the timer }, 0); // then pushes callback to macrotask queue fetch("/api/data") // → Web API handles the network request .then(() => { console.log("3: Fetch done"); // then pushes .then() to microtask queue }); console.log("4: End"); // → Call stack: runs immediately // Output: "1: Start", "4: End", "3: Fetch done", "2: Timeout"
Key insight
The call stack runs synchronous code to completion first. Async callbacks never interrupt running code — they wait in queues until the stack is empty. This is why setTimeout(fn, 0) doesn't run immediately — it waits for the current code to finish.
What is the Event Loop?
The event loop is a continuously running process that coordinates execution between the call stack and the task queues. It follows a simple algorithm on every iteration (called a "tick"):
Execute all synchronous code on the call stack
Run the current script or function to completion. Nothing else happens until the stack is empty.
Drain the entire microtask queue
Execute ALL pending microtasks (Promises, queueMicrotask). If a microtask schedules another microtask, that runs too — the queue must be completely empty before moving on.
Render (if needed)
The browser may update the UI — run requestAnimationFrame callbacks, recalculate styles, layout, and paint. This typically happens every ~16ms (60fps).
Pick ONE macrotask from the task queue
Execute a single macrotask (setTimeout callback, setInterval callback, I/O callback). Then go back to step 2 — drain microtasks again before the next macrotask.
while (true) { // 1. Run all synchronous code (call stack) executeSyncCode(); // 2. Drain ALL microtasks while (microtaskQueue.length > 0) { executeMicrotask(microtaskQueue.shift()); } // 3. Render if needed (~60fps) if (shouldRender()) { runRAFCallbacks(); render(); } // 4. Pick ONE macrotask if (macrotaskQueue.length > 0) { executeMacrotask(macrotaskQueue.shift()); } // → back to step 2 }
The critical rule
Microtasks are drained completely between every macrotask. This means microtasks always have higher priority than macrotasks. A Promise callback will always run before a setTimeout callback, even if the setTimeout was scheduled first.
Macrotasks (Task Queue)
Macrotasks (also just called "tasks") are the standard async callbacks. The event loop picks one macrotask at a time, executes it, then drains all microtasks before picking the next one.
What Creates Macrotasks?
- →setTimeout() / setInterval() — timer callbacks
- →setImmediate() — Node.js only, runs after I/O
- →I/O callbacks — file reads, network responses (Node.js)
- →UI rendering — browser paint/layout cycles
- →MessageChannel — postMessage callbacks
- →DOM event callbacks — click, scroll, keydown handlers
// Each of these creates a macrotask: setTimeout(() => console.log("timeout"), 0); setInterval(() => console.log("interval"), 1000); // DOM event handlers are also macrotasks button.addEventListener("click", () => { console.log("click handler"); // macrotask }); // MessageChannel const channel = new MessageChannel(); channel.port1.onmessage = () => console.log("message"); channel.port2.postMessage("");
setTimeout(fn, 0) is not instant
Even with a delay of 0, setTimeout creates a macrotask that waits until the call stack is empty AND all microtasks are drained. The minimum actual delay is ~4ms in browsers (due to clamping). It's a way to say "run this later, after everything else that's currently queued."
Microtasks (Microtask Queue)
Microtasks have higher priority than macrotasks. The entire microtask queue is drained after every piece of synchronous code and between every macrotask. This makes them run "as soon as possible" — before the browser renders and before any macrotask.
What Creates Microtasks?
- →Promise.then() / .catch() / .finally() — promise callbacks
- →await — the code after await is a microtask
- →queueMicrotask() — explicitly schedule a microtask
- →MutationObserver — DOM mutation callbacks
- →process.nextTick() — Node.js only (even higher priority than Promise)
// Each of these creates a microtask: Promise.resolve().then(() => console.log("promise")); queueMicrotask(() => console.log("queueMicrotask")); // async/await — code after await is a microtask async function example() { console.log("before await"); // sync — runs immediately await fetch("/api"); console.log("after await"); // microtask — runs when promise resolves } // MutationObserver const observer = new MutationObserver(() => { console.log("DOM mutated"); // microtask });
Microtask Queue Draining
The key behavior: when the event loop starts draining microtasks, it doesn't stop until the queue is completely empty. If a microtask schedules another microtask, that new one runs in the same flush — not in the next tick.
Promise.resolve() .then(() => { console.log("micro 1"); // This schedules ANOTHER microtask — it runs immediately // before any macrotask, in the same flush Promise.resolve().then(() => console.log("micro 2")); }); setTimeout(() => console.log("macro 1"), 0); // Output: // "micro 1" // "micro 2" ← runs before macro, even though scheduled later // "macro 1"
This is why microtasks can starve macrotasks
If microtasks keep scheduling more microtasks, the macrotask queue (and rendering) never gets a chance to run. This can freeze the UI. We'll cover this in the Real-World Implications section.
Execution Order
This is the most important section for interviews. The execution order follows a strict priority system:
Synchronous code runs first
All synchronous code in the current execution context runs to completion. console.log, variable assignments, function calls — everything on the call stack finishes before anything async.
Microtask queue is drained completely
ALL pending microtasks run — Promise callbacks, queueMicrotask, await continuations. If a microtask creates another microtask, it runs in the same flush. The queue must be empty.
One macrotask is picked and executed
A single macrotask runs (setTimeout callback, event handler, etc.). After it completes, go back to step 2 — drain microtasks again before the next macrotask.
| Priority | Type | Examples | When It Runs |
|---|---|---|---|
| 1 (highest) | Synchronous | console.log, assignments, loops | Immediately, blocks everything |
| 2 | Microtasks | Promise.then, queueMicrotask, await | After sync code, before macrotasks |
| 3 (lowest) | Macrotasks | setTimeout, setInterval, events | One at a time, after microtask flush |
The golden rule
Sync → Microtasks → Macrotask → Microtasks → Macrotask → ... Memorize this pattern. It answers 90% of "what's the output?" interview questions.
Code Examples
Work through these examples step by step. Try to predict the output before reading the explanation — this is exactly how interviewers test you.
Example 1: Basic Ordering
console.log("1"); setTimeout(() => { console.log("2"); }, 0); Promise.resolve().then(() => { console.log("3"); }); console.log("4");
Output:
1 → 4 → 3 → 2Step 1: console.log("1") — sync, runs immediately
Step 2: setTimeout — registers callback in macrotask queue
Step 3: Promise.then — registers callback in microtask queue
Step 4: console.log("4") — sync, runs immediately
Step 5: Call stack empty → drain microtasks → "3"
Step 6: Microtasks empty → pick macrotask → "2"
Example 2: Nested Microtasks
console.log("start"); setTimeout(() => console.log("timeout"), 0); Promise.resolve() .then(() => { console.log("promise 1"); Promise.resolve().then(() => { console.log("promise 2"); }); }); console.log("end");
Output:
start → end → promise 1 → promise 2 → timeoutKey: "promise 2" runs before "timeout" because it's a microtask created during microtask draining. The microtask queue must be fully empty before any macrotask runs.
Example 3: Mixed Macrotasks & Microtasks
setTimeout(() => { console.log("timeout 1"); Promise.resolve().then(() => console.log("promise inside timeout")); }, 0); setTimeout(() => { console.log("timeout 2"); }, 0); Promise.resolve().then(() => { console.log("promise 1"); }); Promise.resolve().then(() => { console.log("promise 2"); }); console.log("sync");
Output:
sync → promise 1 → promise 2 → timeout 1 → promise inside timeout → timeout 2Step 1: Sync code runs: "sync"
Step 2: Drain microtasks: "promise 1", "promise 2"
Step 3: Pick macrotask: "timeout 1" — this creates a new microtask
Step 4: Drain microtasks again: "promise inside timeout"
Step 5: Pick next macrotask: "timeout 2"
The pattern to remember
After every single macrotask, the event loop drains all microtasks before picking the next macrotask. That's why "promise inside timeout" runs between "timeout 1" and "timeout 2".
Visual Flow
Here's a visual representation of how the event loop coordinates between the call stack and the two queues.
┌─────────────────────────────────────────────────────┐ │ CALL STACK │ │ (executes one function at a time, LIFO) │ │ ┌─────────────────────────────────────────┐ │ │ │ Currently executing function │ │ │ └─────────────────────────────────────────┘ │ └──────────────────────┬──────────────────────────────┘ │ │ When stack is empty... ▼ ┌──────────────────────────────────────────────────────┐ │ EVENT LOOP │ │ │ │ 1. Stack empty? │ │ 2. Drain ALL microtasks ◄──┐ │ │ 3. Render (if needed) │ │ │ 4. Pick ONE macrotask ──────┘ (then back to 2) │ │ │ └───────┬──────────────────────────────┬───────────────┘ │ │ ▼ ▼ ┌───────────────────┐ ┌─────────────────────────┐ │ MICROTASK QUEUE │ │ MACROTASK QUEUE │ │ (high priority) │ │ (normal priority) │ │ │ │ │ │ • Promise.then() │ │ • setTimeout() │ │ • queueMicrotask │ │ • setInterval() │ │ • await resume │ │ • DOM events │ │ • MutationObs. │ │ • MessageChannel │ │ │ │ │ │ Drained FULLY │ │ Picked ONE at a time │ │ between tasks │ │ │ └───────────────────┘ └─────────────────────────┘ ┌──────────────┐ │ WEB APIs │ │ (browser) │ │ │ │ • Timers │ │ • fetch() │ │ • DOM │ │ │ │ Runs async │ │ work, then │ │ pushes to │ │ queues │ └──────────────┘
One Full Event Loop Cycle
── Tick Start ────────────────────────────────────── Call Stack: [script] → runs all sync code → schedules setTimeout (→ macrotask queue) → schedules Promise.then (→ microtask queue) [empty] Microtask Queue: [promise callback] → execute promise callback → (if it creates more microtasks, run those too) [empty] Render: (browser may paint here — ~16ms budget) Macrotask Queue: [setTimeout callback] → execute ONE setTimeout callback → back to microtask drain ── Tick End ────────────────────────────────────────
Real-World Implications
The event loop isn't just an interview topic — it has real consequences for how your app behaves. Here are the patterns that matter in production.
Microtask Starvation
If microtasks keep scheduling more microtasks, the macrotask queue and rendering never get a turn. The UI freezes because the browser can't paint.
// ❌ DANGER: Infinite microtask loop — freezes the browser function recurse() { Promise.resolve().then(() => recurse()); } recurse(); // The microtask queue never empties. // setTimeout callbacks, click handlers, and rendering // are ALL blocked. The page is frozen. // ✅ SAFE: Use setTimeout to yield to the event loop function safeRecurse() { setTimeout(() => safeRecurse(), 0); } // Each iteration is a macrotask, so microtasks and // rendering get a chance to run between iterations.
UI Blocking
Long synchronous code blocks everything — microtasks, macrotasks, and rendering. The browser can't update the screen until the call stack is empty.
// ❌ Blocks UI for ~2 seconds button.addEventListener("click", () => { // Heavy sync computation — nothing else runs for (let i = 0; i < 1_000_000_000; i++) { /* ... */ } updateUI(); // UI only updates AFTER the loop }); // ✅ Break work into chunks with setTimeout button.addEventListener("click", () => { let i = 0; function chunk() { const end = Math.min(i + 1_000_000, 1_000_000_000); for (; i < end; i++) { /* ... */ } if (i < 1_000_000_000) { setTimeout(chunk, 0); // Yield to event loop } else { updateUI(); } } chunk(); });
Rendering Timing
The browser renders between macrotasks (after microtask drain). This means:
- →DOM changes in microtasks are batched and painted together after the microtask flush
- →DOM changes in separate macrotasks may trigger separate paints
- →requestAnimationFrame runs right before the browser paints — ideal for visual updates
- →Multiple synchronous DOM writes are batched into one paint (the browser is smart about this)
When to use which
Use queueMicrotask when you need something to run ASAP after current code (before rendering). Use setTimeout(fn, 0) when you need to yield to the browser so it can render. Use requestAnimationFrame for visual/animation updates.
Performance Insights
Understanding the event loop gives you concrete tools to improve runtime performance and avoid jank.
Break Long Tasks with setTimeout
If a task takes >50ms, it's a 'long task' that blocks rendering. Break it into smaller chunks using setTimeout(fn, 0) to yield to the event loop between chunks.
Avoid Heavy Microtask Chains
Long chains of .then() or recursive microtasks block rendering just like sync code. If you need to process large data, use setTimeout to yield between batches.
Use requestAnimationFrame for Visual Updates
rAF runs right before the browser paints, making it the ideal place for DOM mutations and animation logic. It's synchronized with the display refresh rate (60fps).
Prefer scheduler.yield() (Modern)
The Scheduler API (scheduler.yield()) is a modern way to yield to the event loop while maintaining task priority. It's more predictable than setTimeout for breaking up work.
Use Web Workers for Heavy Computation
Move CPU-intensive work (parsing, sorting, image processing) to a Web Worker. It runs on a separate thread, keeping the main thread free for UI updates.
// Modern approach: scheduler.yield() (Chrome 129+) async function processItems(items) { for (const item of items) { processItem(item); // Yield to the event loop — lets browser render // and higher-priority tasks run await scheduler.yield(); } } // Classic approach: setTimeout chunking function processItemsClassic(items) { let index = 0; function chunk() { const deadline = performance.now() + 5; // 5ms budget while (index < items.length && performance.now() < deadline) { processItem(items[index++]); } if (index < items.length) { setTimeout(chunk, 0); // Yield } } chunk(); }
The 50ms rule
Chrome's Performance panel flags any task over 50ms as a "Long Task." Long tasks block user input and rendering. If your code takes longer than 50ms, break it up. The user won't notice 5ms pauses between chunks, but they will notice a 200ms freeze.
Common Mistakes
These misconceptions trip up developers in interviews and cause real bugs in production code.
Thinking Promise runs before setTimeout because it's 'faster'
Developers often think Promises are faster than setTimeout. That's not why they run first. Promises create microtasks which have higher priority in the event loop — they're drained completely before any macrotask runs.
✅Frame it correctly: microtasks have higher priority than macrotasks. It's about queue priority, not speed.
Assuming setTimeout(fn, 0) runs immediately
setTimeout(fn, 0) doesn't mean 'run now.' It means 'run after the current call stack is empty, all microtasks are drained, and the browser has had a chance to render.' The actual minimum delay is ~4ms.
✅Use setTimeout(fn, 0) to yield to the event loop, not for 'immediate' execution. Use queueMicrotask for truly ASAP execution.
Blocking the main thread with sync code
A for loop processing 1 million items blocks everything — no rendering, no event handling, no async callbacks. The page appears frozen.
✅Break heavy work into chunks with setTimeout or use Web Workers for CPU-intensive tasks.
Infinite microtask loops
A microtask that schedules another microtask creates an infinite loop that never yields. The macrotask queue and rendering are starved — the browser tab crashes.
✅Use setTimeout for recursive patterns that need to yield. Reserve microtasks for short, non-recursive work.
Expecting DOM updates to be visible in microtasks
DOM changes made in microtasks are batched and only painted after the microtask queue is drained. You can't see intermediate DOM states within a microtask chain.
✅Use requestAnimationFrame or setTimeout if you need the browser to paint between updates.
Confusing async/await with synchronous code
Code after 'await' looks synchronous but it's actually a microtask. The function pauses and other code runs before it resumes. This causes subtle ordering bugs.
✅Remember: await = 'pause here, schedule the rest as a microtask when the promise resolves.'
Interview Questions
These are the most commonly asked event loop questions in frontend interviews. Practice explaining each one out loud — clarity matters as much as correctness.
Q:What is the event loop and why does JavaScript need it?
A: The event loop is a mechanism that allows JavaScript to handle async operations despite being single-threaded. It continuously checks if the call stack is empty, then moves callbacks from task queues to the stack for execution. Without it, JavaScript couldn't handle timers, network requests, or user events without blocking the entire thread.
Q:What is the difference between microtasks and macrotasks?
A: Microtasks (Promise.then, queueMicrotask, await) have higher priority and are drained completely between every macrotask. Macrotasks (setTimeout, setInterval, DOM events) are picked one at a time. After each macrotask, all microtasks run before the next macrotask. This means Promise callbacks always run before setTimeout callbacks.
Q:What is the execution order of sync code, Promises, and setTimeout?
A: Synchronous code runs first (call stack). Then all microtasks (Promise.then callbacks) are drained. Then one macrotask (setTimeout callback) runs. Then microtasks are drained again. The pattern is: Sync → Microtasks → Macrotask → Microtasks → Macrotask → ...
Q:Why does Promise.then() run before setTimeout(fn, 0)?
A: Because Promise.then() creates a microtask and setTimeout creates a macrotask. The event loop always drains the entire microtask queue before picking the next macrotask. It's not about speed — it's about queue priority. Microtasks always have higher priority than macrotasks.
Q:Can microtasks block rendering?
A: Yes. The browser renders between macrotasks, after microtask drain. If microtasks keep scheduling more microtasks (recursive Promises), the microtask queue never empties, and the browser never gets a chance to paint. This freezes the UI. Always ensure microtask chains terminate.
Q:What is the difference between queueMicrotask and setTimeout(fn, 0)?
A: queueMicrotask schedules a microtask — it runs before rendering and before any macrotask. setTimeout(fn, 0) schedules a macrotask — it runs after microtasks are drained and potentially after a render. Use queueMicrotask for ASAP execution, setTimeout for yielding to the browser.
Q:How does async/await relate to the event loop?
A: async/await is syntactic sugar over Promises. Code before the first await runs synchronously. The await pauses the function and schedules the rest as a microtask when the awaited promise resolves. The function resumes in the microtask queue, not immediately.
Q:How would you break up a long-running task to avoid blocking the UI?
A: Split the work into chunks and use setTimeout(fn, 0) between chunks to yield to the event loop. This lets the browser render and handle user input between chunks. Modern alternative: scheduler.yield(). For CPU-heavy work, use Web Workers to move computation off the main thread entirely.
Q:Where does requestAnimationFrame fit in the event loop?
A: rAF callbacks run after microtasks are drained and right before the browser paints — between the microtask flush and the next macrotask. It's synchronized with the display refresh rate (~60fps). This makes it ideal for visual updates and animations because changes are applied just before the user sees them.
Q:What happens if you call setState inside a Promise.then() in React?
A: The setState runs as a microtask. In React 18+, state updates are automatically batched regardless of where they're called (sync, promises, timeouts). In React 17 and earlier, setState inside a Promise was NOT batched — each call triggered a separate re-render. This is a common interview follow-up.
Practice Section
Test your understanding with these scenario-based questions. Try to work through each one before reading the answer.
Predict the Output
What's the output? console.log("A"); setTimeout(() => console.log("B"), 0); Promise.resolve().then(() => { console.log("C"); setTimeout(() => console.log("D"), 0); }); Promise.resolve().then(() => console.log("E")); console.log("F");
Answer: A → F → C → E → B → D. Sync runs first (A, F). Then microtasks drain (C, E — in order). C schedules a new setTimeout (D) which goes to the macrotask queue behind B. Then macrotasks run one at a time: B, then D.
UI Freezing
A user clicks a button that triggers a function processing 500,000 array items synchronously. The loading spinner doesn't appear until after processing is done. Why?
Answer: The browser can only render when the call stack is empty. The synchronous loop occupies the call stack for the entire duration, so the browser never gets a chance to paint the spinner. Fix: break the work into chunks with setTimeout(fn, 0) between them, or show the spinner first and start processing in the next macrotask. Even better: use a Web Worker.
Fixing Execution Order
You need to ensure a cleanup function runs AFTER all pending Promise callbacks but BEFORE the next setTimeout. How?
Answer: Use queueMicrotask(() => cleanup()). Microtasks are drained completely before any macrotask runs, so your cleanup will execute after all currently pending Promise.then() callbacks (they're all microtasks in the same flush) but before any setTimeout callback. This is the precise use case for queueMicrotask.
Cheat Sheet (Quick Revision)
One-screen summary for quick revision before interviews.
Quick Revision Cheat Sheet
Event Loop: Moves tasks from queues → call stack when stack is empty. Runs continuously.
Execution order: Sync code → ALL microtasks → ONE macrotask → ALL microtasks → repeat.
Microtasks: Promise.then, queueMicrotask, await, MutationObserver. High priority. Drained fully.
Macrotasks: setTimeout, setInterval, DOM events, MessageChannel. One at a time.
Call Stack: LIFO. Executes one function at a time. Must be empty for async to run.
Web APIs: Browser handles async work (timers, fetch, DOM). Pushes callbacks to queues.
setTimeout(fn, 0): Not instant. Runs after sync + microtasks + possible render. Min ~4ms delay.
queueMicrotask: Runs ASAP after current code, before rendering and macrotasks.
requestAnimationFrame: Runs before paint, after microtasks. Ideal for visual updates. ~60fps.
Microtask starvation: Recursive microtasks block macrotasks and rendering. Page freezes.
Long tasks: >50ms blocks UI. Break with setTimeout or scheduler.yield().
async/await: Before await = sync. After await = microtask when promise resolves.
React 18 batching: State updates batched everywhere (sync, promises, timeouts). Not in React 17.