Event LoopMicrotasksMacrotasksAsync JavaScriptPromises

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.

30 min read14 sections
01

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.

02

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.

execution-model.jsjavascript
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.

03

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"):

1

Execute all synchronous code on the call stack

Run the current script or function to completion. Nothing else happens until the stack is empty.

2

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.

3

Render (if needed)

The browser may update the UI — run requestAnimationFrame callbacks, recalculate styles, layout, and paint. This typically happens every ~16ms (60fps).

4

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.

Event Loop Algorithmtext
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.

04

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
macrotask-examples.jsjavascript
// 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."

05

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)
microtask-examples.jsjavascript
// 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.

microtask-chaining.jsjavascript
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.

06

Execution Order

This is the most important section for interviews. The execution order follows a strict priority system:

1

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.

2

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.

3

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.

PriorityTypeExamplesWhen It Runs
1 (highest)Synchronousconsole.log, assignments, loopsImmediately, blocks everything
2MicrotasksPromise.then, queueMicrotask, awaitAfter sync code, before macrotasks
3 (lowest)MacrotaskssetTimeout, setInterval, eventsOne 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.

07

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

example-1.jsjavascript
console.log("1");

setTimeout(() => {
  console.log("2");
}, 0);

Promise.resolve().then(() => {
  console.log("3");
});

console.log("4");

Output:

1 → 4 → 3 → 2

Step 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

example-2.jsjavascript
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 → timeout

Key: "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

example-3.jsjavascript
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 2

Step 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".

08

Visual Flow

Here's a visual representation of how the event loop coordinates between the call stack and the two queues.

Event Loop Architecturetext

┌─────────────────────────────────────────────────────┐
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

One Tick Walkthroughtext
── 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 ────────────────────────────────────────
09

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.

microtask-starvation.jsjavascript
// ❌ 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.

ui-blocking.jsjavascript
// ❌ 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.

10

Performance Insights

Understanding the event loop gives you concrete tools to improve runtime performance and avoid jank.

✓ Done

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.

✓ Done

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.

✓ Done

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).

→ Could add

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.

→ Could add

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.

scheduler-yield.jsjavascript
// 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.

11

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.'

12

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.

13

Practice Section

Test your understanding with these scenario-based questions. Try to work through each one before reading the answer.

1

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.

2

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.

3

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.

14

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.