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
setTimeoutdoes not. - Need the result before the next paint? Microtask. Can it wait a frame? Macrotask.
Quick example
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. MacrotaskSynchronous 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
| Microtasks | Macrotasks | |
|---|---|---|
| Examples | Promise.then, queueMicrotask(), MutationObserver | setTimeout, setInterval, fetch, DOM events |
| Execution timing | After current script, before rendering | One per cycle, after rendering check |
| Blocks rendering | Yes, if many queue up | No, browser renders between each |
| Queue behavior | Fully drained each cycle | One task dequeued per cycle |
| When to use | State changes, DOM reads, validation | Delays, 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
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
// 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
// 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
// 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 callbackMistake 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
setStatecalls usingPromise.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
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
// timeoutBoth .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
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
// 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 readyA concise answer to help you respond confidently on this topic during an interview.