Skip to main content

Event Loop: Microtasks vs Macrotasks

Event Loop is JavaScript's single-thread task scheduler: it runs all synchronous code first, drains the entire microtask queue (Promises, queueMicrotask), optionally renders, then picks one macrotask (setTimeout, I/O), and repeats.

Theory

TL;DR

  • Restaurant analogy: one chef (call stack), priority tickets (microtasks), regular orders (macrotasks). All priority tickets clear before the next regular order starts.
  • Microtasks run as a full batch after every synchronous block. Macrotasks run one at a time, with rendering checks between them.
  • Microtasks block rendering if they keep queuing each other. A single setTimeout does not.
  • Need the result before the next paint? Microtask. Can it wait a frame? Macrotask.

Quick example

javascript
console.log('1. Sync'); setTimeout(() => console.log('2. Macrotask'), 0); Promise.resolve() .then(() => console.log('3. Microtask')); console.log('4. Sync'); // Output: // 1. Sync // 4. Sync // 3. Microtask // 2. Macrotask

Synchronous code runs first, top to bottom. Then microtasks. Then macrotasks, even with a 0ms delay set on the timer.

Key difference

Microtasks drain completely before anything else moves forward. If a microtask queues another microtask, that one also runs in the same cycle, before any rendering or macrotask. Macrotasks are different: the browser picks exactly one per cycle, checks whether rendering is needed, then checks microtasks again. That is why a hundred chained Promises can freeze a UI, but a setTimeout(..., 0) loop will not.

When to use

  • Microtasks: state updates that must apply before the user sees anything (React batching, Vue reactivity), DOM measurements, input validation that feeds the same render
  • Macrotasks: timers, I/O (fetch, file reading), user events (click, scroll), anything that can safely yield to the browser between frames

If the work must finish before the next paint, use a microtask. If it can wait a frame, use a macrotask.

Comparison table

MicrotasksMacrotasks
ExamplesPromise.then, queueMicrotask(), MutationObserversetTimeout, setInterval, fetch, DOM events
Execution timingAfter current script, before renderingOne per cycle, after rendering check
Blocks renderingYes, if many queue upNo, browser renders between each
Queue behaviorFully drained each cycleOne task dequeued per cycle
When to useState changes, DOM reads, validationDelays, I/O, user interactions

How the engine handles this

The JavaScript engine keeps two separate queues. After the call stack empties, MicrotaskQueue::RunMicrotasks() runs until the queue is empty. Only then does the browser check whether a repaint is needed. Only then does it pull one macrotask. Microtasks are part of the current execution context. Macrotasks represent work arriving from outside.

Common mistakes

Mistake 1: assuming setTimeout(..., 0) runs right away

javascript
setTimeout(() => console.log('fast'), 0); console.log('slow'); // Output: slow, fast // setTimeout is a macrotask. It always waits for the microtask queue to empty.

If you need something to run right after the current script, use Promise.resolve().then() or queueMicrotask(), not setTimeout.

Mistake 2: starving the UI with recursive microtasks

javascript
// Bad: the browser never gets to render function starveUI() { function loop() { Promise.resolve().then(loop); // queues itself as a microtask } loop(); } // Fix: yield with a macrotask function safeLoop() { function step() { console.log('step'); setTimeout(step, 0); // browser can render between steps } step(); }

Mistake 3: thinking await inside a loop yields to the browser

javascript
// Wrong: awaiting a resolved Promise is still a microtask async function processItems(items) { for (const item of items) { await Promise.resolve(); // microtask, rendering does NOT happen here expensiveCalculation(item); } } // Right: yield with setTimeout so the browser can paint between iterations async function processItems(items) { for (const item of items) { await new Promise(r => setTimeout(r, 0)); // macrotask, browser can render expensiveCalculation(item); } }

Mistake 4: underestimating MutationObserver in tight loops

javascript
// Bad: 1000 DOM changes = 1000 microtask callbacks, all before any rendering const observer = new MutationObserver(() => updateUI()); for (let i = 0; i < 1000; i++) { element.textContent = i; } // Fix: batch the DOM change into one update element.textContent = 999; // one mutation, one callback

Mistake 5: assuming Node.js and browser behavior match

In browsers and Node.js v11+, microtasks always run before the next macrotask. In Node.js v10 and earlier, timer callbacks ran before microtasks in certain phases of the event loop. If you support older Node environments, verify behavior there rather than assuming it.

Real-world usage

  • React: batches multiple setState calls using Promise.resolve().then() internally, so one event handler triggers one render instead of many
  • Vue: reactive updates go through Promise.then() for the same batching reason
  • Node.js streams: use macrotasks between data chunks to avoid I/O backpressure blocking the event loop
  • Intersection Observer: callbacks run as macrotasks, so the browser can render between observations
  • Express: async middleware runs in the microtask queue within a single request cycle

In practice, the starvation bug shows up in a subtle form: you await somePromise inside a loop expecting to yield to the browser, but if that Promise is already resolved, it queues a microtask. Rendering never happens.

Follow-up questions

Q: Why do microtasks run before macrotasks?
A: Microtasks represent work that belongs to the current execution context, like a Promise resolving after synchronous code. Macrotasks represent new work arriving from outside (a timer firing, a network response). Draining microtasks first ensures all dependent async work finishes before the browser handles something new.

Q: What happens if a microtask queues another microtask?
A: It runs in the same cycle. The event loop does not move to macrotasks until the microtask queue is completely empty. This is exactly how starvation happens.

Q: How does async/await fit into this?
A: async/await is syntax over Promises. When you hit an await, the function pauses and the rest of it is queued as a microtask. The function resumes in the microtask queue, not the macrotask queue.

Q: What is the difference between queueMicrotask() and Promise.resolve().then()?
A: Both add callbacks to the same microtask queue. queueMicrotask() does not allocate a Promise object, so it is slightly cheaper. Use it when you want a microtask without needing error handling or chaining.

Q: (Senior) A React app drops frames during a large data update. Microtask buildup is suspected. How do you fix it?
A: Move the expensive work to macrotasks with setTimeout(..., 0) so the browser can render between batches. In React 18+, wrap non-urgent updates in startTransition(), which lets React pause and yield between chunks. For fine-grained control, requestIdleCallback() runs work only when the browser has idle time, and requestAnimationFrame() ties work to the paint cycle.

Examples

Basic: execution order

javascript
console.log('start'); setTimeout(() => console.log('timeout'), 0); Promise.resolve() .then(() => console.log('promise 1')) .then(() => console.log('promise 2')); console.log('end'); // Output: // start // end // promise 1 // promise 2 // timeout

Both .then() callbacks run before setTimeout because each chained .then() queues a new microtask right after the previous one resolves. The entire chain drains before the macrotask gets a turn.

Intermediate: React state batching

javascript
function handleClick() { setCount(c => c + 1); // queued as microtask setCount(c => c + 1); // queued as microtask console.log('handler done'); // state has not updated yet at this point } // React collects both updates in the microtask queue, // then applies them together after the handler finishes. // Result: one re-render, not two.

React uses Promise.resolve().then() internally to collect all state changes from a single event and apply them in one render pass. Without this batching, each setCount call would trigger a separate render.

Senior: starvation vs proper yielding

javascript
// Bad: freezes the browser completely function starve() { function tick() { Promise.resolve().then(tick); } tick(); // Each microtask queues the next one. // Rendering never happens. } // Better: yields to the browser between iterations function safeYield() { function tick() { // do some work setTimeout(tick, 0); // browser can repaint between calls } tick(); } // React 18+: startTransition for non-urgent updates import { startTransition } from 'react'; startTransition(() => { setItems(buildLargeList()); // React can pause and render intermediate state });

startTransition marks the update as non-urgent. React can interrupt the work mid-way, render what is ready, and continue. The page stays responsive instead of freezing for the full computation.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?