Microtasks, Macrotasks, and Rendering
The Classic Output Question
Every senior frontend interview has a version of this:
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');
Output: A, F, C, E, B, D.
You probably knew that. But can you explain exactly why C and E group together, why they come before B, and what this has to do with rendering? That's the difference between memorizing patterns and understanding the machine.
The Mental Model
Think of the event loop as a train station. Each macrotask is a train arriving at the platform. Between trains, every passenger on the VIP waiting list (microtask queue) boards immediately — all of them, including anyone who joins the list while others are boarding. Only when the VIP list is completely empty does the next train pull up. And periodically, the station updates its departure board (renders the screen).
What Makes Something a Microtask vs. a Macrotask
This isn't arbitrary. The categorization follows a principle: microtasks are reactions to something that already happened in the current task. Macrotasks are new units of work from external sources.
Microtask Sources
| Source | Why it's a microtask |
|---|---|
Promise.then/catch/finally | Reacting to a promise that settled during the current task |
queueMicrotask(fn) | Explicit microtask scheduling |
MutationObserver | Reacting to DOM mutations that happened during the current task |
process.nextTick (Node.js) | Historical — runs before other microtasks in Node |
Macrotask Sources
| Source | Why it's a macrotask |
|---|---|
setTimeout/setInterval | External timer event — new work |
| User input events (click, keydown) | External user action — new work |
MessageChannel.onmessage | Message from another context — new work |
setImmediate (Node.js) | Explicit next-task scheduling |
| I/O callbacks | External I/O completion — new work |
The Ordering Rules, Precisely
Here's the exact flow the browser follows after executing a task:
1. Execute ONE task from a task queue
2. DRAIN the microtask queue (all of them, including newly added ones)
3. If rendering opportunity:
a. Run resize/scroll event handlers
b. Run requestAnimationFrame callbacks
c. Style → Layout → Paint → Composite
4. Go to step 1
The critical point is step 2: drain means execute every microtask, check if more were added, execute those, check again — until the queue is completely empty. Only then does the loop proceed.
Proof by Example
// Task: script execution
setTimeout(() => {
// This is a NEW task (macrotask)
console.log('timeout 1');
Promise.resolve().then(() => console.log('micro inside timeout'));
}, 0);
Promise.resolve().then(() => {
console.log('micro 1');
Promise.resolve().then(() => console.log('micro 2'));
});
console.log('sync');
Final output: sync, micro 1, micro 2, timeout 1, micro inside timeout.
Notice: micro 2 runs before timeout 1 even though timeout 1 was registered first. Microtasks always win.
The Rendering Connection
This is where most tutorials stop. They explain microtask vs macrotask ordering but ignore the rendering pipeline. That's a mistake, because understanding when rendering happens is essential for building smooth UIs.
// Will the user see the red color?
element.style.backgroundColor = 'red';
element.style.backgroundColor = 'blue';
No. Both style changes happen within the same task. The browser only renders at the render step, which comes after the task and microtask checkpoint. By that time, the computed style is blue. The red frame never paints.
// Will the user see the red color?
element.style.backgroundColor = 'red';
requestAnimationFrame(() => {
element.style.backgroundColor = 'blue';
});
Still no (in most cases). rAF runs in the same render step. The browser batches style changes and renders the final result. Both changes are processed before paint.
// WILL the user see the red color?
element.style.backgroundColor = 'red';
requestAnimationFrame(() => {
requestAnimationFrame(() => {
element.style.backgroundColor = 'blue';
});
});
Yes. The first rAF sets up a callback for the next frame. So the browser paints red in frame 1, then in frame 2 the inner rAF runs and changes it to blue. The double-rAF trick is a well-known pattern for ensuring a visual change is painted before the next change.
Production Scenario: Microtask-based State Batching
React's state batching in React 18+ uses the microtask queue. When you call setState multiple times:
function handleClick() {
setCount(1); // doesn't trigger render yet
setName('Bob'); // doesn't trigger render yet
setAge(25); // doesn't trigger render yet
// React schedules ONE re-render as a microtask
}
React batches all state updates within the same event handler and schedules a single render as a microtask after the handler completes. This is why:
- You don't see intermediate renders (count=1 but name still old)
- The re-render happens before the next macrotask
- The re-render happens before the browser paints
This wasn't always the case. Before React 18, batching only worked inside React event handlers. setTimeout callbacks, fetch .then() callbacks, and native event listeners would trigger synchronous re-renders for each setState. React 18's "automatic batching" extended this by using the microtask queue consistently.
How React 18 automatic batching uses microtasks
Before React 18, calling setState inside a setTimeout would trigger a synchronous re-render for each call. React 18 uses queueMicrotask (or a similar mechanism) to batch all state updates in a microtask. The re-render runs during the microtask checkpoint after the current task. This means: (1) all setState calls within a setTimeout are batched, (2) the re-render runs before any timers or rendering, and (3) flushSync() is the escape hatch when you need a synchronous re-render within the task.
The queueMicrotask API
queueMicrotask is the explicit way to schedule a microtask. It was added to give developers the same power that Promise.resolve().then() provides, without the overhead of creating a Promise:
// These are functionally identical:
Promise.resolve().then(() => doWork());
queueMicrotask(() => doWork());
// But queueMicrotask is:
// 1. More explicit in intent — "I want a microtask"
// 2. Slightly faster — no Promise allocation
// 3. Clearer in stack traces — no Promise wrapper
Use queueMicrotask when you need to defer work until after the current synchronous code completes but before any macrotask or rendering. Common use cases:
- Batching multiple synchronous operations into one async reaction
- Ensuring a callback runs after the current call stack unwinds
- Library internals that need microtask timing without Promise semantics
Don't use queueMicrotask to "yield to the browser." Microtasks run before rendering. If you're trying to let the browser paint between chunks of work, you need setTimeout or MessageChannel. Microtasks are for deferring within the same event loop turn, not for creating breathing room.
Common Mistakes
| What developers do | What they should do |
|---|---|
| Using 'macrotask' as an official spec term The spec defines 'task queues' and a 'microtask queue.' There is no 'macrotask queue.' Knowing this helps when reading the actual spec. | The spec calls them 'tasks.' 'Macrotask' is informal jargon from the community to distinguish from microtasks. |
| Thinking Promise.resolve().then(fn) is asynchronous like setTimeout(fn, 0) They're both 'async' in the sense they don't execute synchronously, but they have fundamentally different timing guarantees. | Promise.then is microtask-async (runs before next task). setTimeout is macrotask-async (runs as a new task). |
| Assuming rendering happens between every task The browser optimizes by skipping render steps when nothing changed or when the display refresh isn't due yet. | Rendering happens roughly every 16.6ms (60fps) and only when there are visual changes. Many tasks run between render steps. |
| Using Promise chains to animate (expecting each .then to render a frame) All microtasks run during the microtask checkpoint, which happens before the render step. No painting occurs between Promise callbacks. | Use requestAnimationFrame for animations. Microtasks all drain before rendering, so chained .then() calls never produce intermediate frames. |
Challenge: Complex Ordering
Challenge: Mixed Micro and Macro
console.log('start');
setTimeout(() => {
console.log('timeout 1');
Promise.resolve().then(() => console.log('promise inside timeout'));
}, 0);
new Promise((resolve) => {
console.log('promise executor');
resolve();
}).then(() => {
console.log('promise then');
setTimeout(() => console.log('timeout inside promise'), 0);
});
console.log('end');
Show Answer
Output: start, promise executor, end, promise then, timeout 1, promise inside timeout, timeout inside promise
Trace:
console.log('start')— synchronous. Output: startsetTimeoutregisters callback (task queue)- Promise executor runs synchronously — this is a common trap. The function passed to
new Promise()runs immediately. Output: start, promise executor resolve()is called,.then()schedules callback to microtask queueconsole.log('end')— synchronous. Output: start, promise executor, end- Script task ends. Microtask checkpoint: run
promise then. Output: ..., promise then - Inside that microtask,
setTimeoutregisters another callback (task queue). Microtask queue empty. - Next task:
timeout 1runs. Output: ..., timeout 1 - Inside that task, Promise.then schedules microtask. Microtask checkpoint: run it. Output: ..., promise inside timeout
- Next task:
timeout inside promiseruns. Output: ..., timeout inside promise
Key insight: the Promise executor (the function passed to new Promise()) is synchronous. Only .then()/.catch()/.finally() callbacks are microtasks.
Key Rules
- 1Microtasks always drain completely between macrotasks. There is no interleaving — all pending microtasks run before the next task.
- 2The Promise executor (function passed to new Promise()) runs SYNCHRONOUSLY. Only .then/.catch/.finally callbacks are microtasks.
- 3Rendering (style, layout, paint) happens after microtask drain, not after every task. Multiple tasks may execute between renders.
- 4queueMicrotask is the explicit microtask API. Use it instead of Promise.resolve().then() when you want microtask timing without Promise semantics.
- 5To yield to rendering, you MUST use a macrotask (setTimeout, MessageChannel). Microtasks run before rendering and will never yield a paint frame.