Skip to content

Microtasks, Macrotasks, and Rendering

intermediate14 min read

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

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

SourceWhy it's a microtask
Promise.then/catch/finallyReacting to a promise that settled during the current task
queueMicrotask(fn)Explicit microtask scheduling
MutationObserverReacting to DOM mutations that happened during the current task
process.nextTick (Node.js)Historical — runs before other microtasks in Node

Macrotask Sources

SourceWhy it's a macrotask
setTimeout/setIntervalExternal timer event — new work
User input events (click, keydown)External user action — new work
MessageChannel.onmessageMessage from another context — new work
setImmediate (Node.js)Explicit next-task scheduling
I/O callbacksExternal I/O completion — new work
Quiz
Which of these is a microtask?

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');
Execution Trace
Task
Script runs as the initial task
Call stack: [script]
Line 1
setTimeout registers callback to task queue
Task queue: [timeout1]
Line 7
Promise.then schedules 'micro 1' to microtask queue
Microtask queue: [micro1]
Line 12
console.log('sync') executes
Output: sync
Drain
Task complete. Microtask checkpoint begins.
Draining microtask queue...
Micro 1
'micro 1' runs. Inside, it schedules 'micro 2'
Output: sync, micro 1 | Microtask queue: [micro2]
Micro 2
Still draining. 'micro 2' runs.
Output: sync, micro 1, micro 2
Empty
Microtask queue empty. Checkpoint complete.
Proceed to next task
Task 2
Event loop picks setTimeout callback. 'timeout 1' runs.
Output: sync, micro 1, micro 2, timeout 1
Drain 2
Microtask checkpoint: 'micro inside timeout' runs
Output: sync, micro 1, micro 2, timeout 1, micro inside timeout

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.

Quiz
You set element.style.opacity = '0' and then immediately set element.style.opacity = '1' in the same task. What does the user see?

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:

  1. You don't see intermediate renders (count=1 but name still old)
  2. The re-render happens before the next macrotask
  3. 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
Common Trap

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 doWhat 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:

  1. console.log('start') — synchronous. Output: start
  2. setTimeout registers callback (task queue)
  3. Promise executor runs synchronously — this is a common trap. The function passed to new Promise() runs immediately. Output: start, promise executor
  4. resolve() is called, .then() schedules callback to microtask queue
  5. console.log('end') — synchronous. Output: start, promise executor, end
  6. Script task ends. Microtask checkpoint: run promise then. Output: ..., promise then
  7. Inside that microtask, setTimeout registers another callback (task queue). Microtask queue empty.
  8. Next task: timeout 1 runs. Output: ..., timeout 1
  9. Inside that task, Promise.then schedules microtask. Microtask checkpoint: run it. Output: ..., promise inside timeout
  10. Next task: timeout inside promise runs. 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

Key Rules
  1. 1Microtasks always drain completely between macrotasks. There is no interleaving — all pending microtasks run before the next task.
  2. 2The Promise executor (function passed to new Promise()) runs SYNCHRONOUSLY. Only .then/.catch/.finally callbacks are microtasks.
  3. 3Rendering (style, layout, paint) happens after microtask drain, not after every task. Multiple tasks may execute between renders.
  4. 4queueMicrotask is the explicit microtask API. Use it instead of Promise.resolve().then() when you want microtask timing without Promise semantics.
  5. 5To yield to rendering, you MUST use a macrotask (setTimeout, MessageChannel). Microtasks run before rendering and will never yield a paint frame.