Event Loop (Macro vs Microtasks)

TL;DR / The browser event loop processes one macrotask, then drains all microtasks, then optionally renders — and the order determines when your async code actually runs.

How It Works

 Event Loop Cycle

 ┏━━━━━━━━━━━━━━┓        ┏━━━━━━━━━━━━━━━━━━━┓        ╭─────────────────╮
 ┃ 1. Macrotask ┃───────→┃ 2. ALL Microtasks ┃───────→│ 3. Render (rAF) │  repeat
 ┗━━━━━━━━━━━━━━┛        ┗━━━━━━━━━━━━━━━━━━━┛        ╰─────────────────╯


 Microtask sources

 ╭──────────────╮   ╭────────────────╮   ╭─────────────╮
 │ Promise.then │   │ queueMicrotask │   │ MutationObs │
 ╰──────────────╯   ╰────────────────╯   ╰─────────────╯


 Macrotask sources

 ╭────────────╮   ╭─────────────╮   ╭───────────────╮   ╭───────────╮
 │ setTimeout │   │ setInterval │   │ I/O callbacks │   │ UI events │
 ╰────────────╯   ╰─────────────╯   ╰───────────────╯   ╰───────────╯

Edit diagram

JavaScript is single-threaded. All code — synchronous and asynchronous — runs on one thread managed by the event loop. Understanding the event loop is understanding when your code runs relative to everything else happening in the browser.

The Event Loop Cycle

Each iteration of the event loop follows this sequence:

  1. Execute the current macrotask until the call stack is empty. This might be a setTimeout callback, a user interaction handler, or the initial script evaluation.
  2. Drain the microtask queue. Process every microtask currently queued, plus any microtasks queued by those microtasks, until the queue is empty. This includes Promise.then/catch/finally, queueMicrotask, and MutationObserver callbacks.
  3. Render (if needed). The browser checks whether it is time to update the display (typically targeting 60fps = every 16.6ms). If so, it runs requestAnimationFrame callbacks, recalculates styles, performs layout, paints, and composites.
  4. Pick the next macrotask from the macrotask queue and start the cycle again. If no macrotask is available, the loop idles until one arrives.

The critical distinction: after each macrotask, the browser drains all microtasks before considering rendering or the next macrotask. This means microtasks always run before the browser has a chance to paint.

Macrotasks

Macrotasks (sometimes just called "tasks") are scheduled by:

  • setTimeout and setInterval
  • setImmediate (Node.js)
  • I/O operations and network callbacks
  • UI events (click, scroll, keypress)
  • MessageChannel and postMessage

Each macrotask runs to completion before the next one starts. Between macrotasks, the browser can render. This is why setTimeout(fn, 0) defers work until after the current macrotask and any pending microtasks — it schedules fn as a new macrotask.

Microtasks

Microtasks are scheduled by:

  • Promise.then, catch, finally
  • await continuations (the code after an await runs as a microtask when the promise resolves)
  • queueMicrotask(fn)
  • MutationObserver

Microtasks run immediately after the synchronous code completes and before the browser renders. They have higher priority than macrotasks. If a microtask queues another microtask, that new microtask also runs before the browser yields — the entire microtask queue must be empty before proceeding.

Practical Implications

Promise chains are synchronous from the browser's perspective. A chain of .then() calls all execute within the same microtask drain phase. The browser cannot render between them.

setTimeout(fn, 0) does not mean "run immediately." It means "schedule a macrotask." That macrotask won't run until the current macrotask finishes, all microtasks drain, and the browser has had a chance to render (if it chooses to). In practice, browsers also enforce a minimum delay (typically 1ms, or 4ms after 5 nested calls).

requestAnimationFrame is neither micro nor macro. It runs during the rendering phase, after microtasks but before paint. It is the right place for visual updates because it aligns with the browser's refresh rate.

await splits a function into microtask chunks. Code before await runs synchronously. Code after await is scheduled as a microtask when the awaited promise resolves. This means await does not yield to the event loop the way setTimeout does — it only yields to other microtasks.

Node.js Differences

Node.js has a more complex event loop with multiple phases: timers, pending callbacks, idle, poll, check, close. It also introduces process.nextTick, which runs before other microtasks (even Promise.then). The conceptual model is the same — microtasks before macrotasks — but the granularity is finer.

Gotchas

  • Microtask starvation is real. A microtask that recursively queues more microtasks blocks the event loop forever. The browser cannot render, and macrotasks (like user clicks) never process. See the task starvation article for details.
  • async/await does not yield to the render cycle. If you await a chain of already-resolved promises, all continuations run as microtasks in one batch. The browser never gets a chance to paint between them.
  • setTimeout inside requestAnimationFrame schedules the callback for the next macrotask, not the next frame. Use requestAnimationFrame again if you want frame-aligned execution.
  • Event handlers are macrotasks when triggered by user interaction, but run synchronously (inline) when triggered programmatically via element.click(). This difference can cause inconsistent batching behavior between real and synthetic events in frameworks like React.
  • queueMicrotask is preferable to Promise.resolve().then(fn) for scheduling microtasks — it is more explicit in intent and slightly more efficient (no promise allocation).