Skip to main content

Microtasks and Microtask Queue in JavaScript

JavaScript is single-threaded, meaning it can only execute one piece of code at a time. Yet it handles dozens of asynchronous operations simultaneously: timers, network requests, user events, promise callbacks, DOM mutations. The secret is the event loop, which orchestrates when each piece of code runs. Within this system, not all asynchronous tasks are equal. Some run sooner than others, and the distinction between microtasks and macrotasks determines exactly when.

Understanding this distinction is not academic. It explains why a .then() callback runs before a setTimeout(..., 0), why the UI can freeze even without long-running code, and why certain code patterns produce surprising execution orders. This guide breaks down the microtask queue, shows you exactly when microtasks and macrotasks execute relative to each other and to rendering, and explains why it matters for building responsive applications.

The Microtask Queue vs. the Macrotask Queue

JavaScript manages asynchronous work through two separate queues with different priority levels.

Macrotasks (Task Queue)

Macrotasks are the "regular" asynchronous tasks. Each one represents a complete, self-contained unit of work. The event loop picks up one macrotask at a time from the queue.

Examples of macrotasks:

  • setTimeout / setInterval callbacks
  • I/O operations (network responses, file reads)
  • UI rendering
  • requestAnimationFrame callbacks
  • User interaction events (click, keypress, scroll)
  • MessageChannel / postMessage
  • setImmediate (Node.js)

Microtasks (Microtask Queue)

Microtasks are higher-priority tasks that execute between macrotasks. After the currently running code finishes and before the event loop picks up the next macrotask, it empties the entire microtask queue.

Examples of microtasks:

  • .then() / .catch() / .finally() handlers on promises
  • await continuations in async functions
  • queueMicrotask() callbacks
  • MutationObserver callbacks

The Priority Difference

The critical rule: the entire microtask queue is drained before the next macrotask runs. No macrotask can execute while microtasks are waiting.

console.log("1: Script start");

setTimeout(() => {
console.log("2: setTimeout (macrotask)");
}, 0);

Promise.resolve().then(() => {
console.log("3: Promise .then() (microtask)");
});

console.log("4: Script end");

Output:

1: Script start
4: Script end
3: Promise .then() (microtask)
2: setTimeout (macrotask)

Even though both setTimeout and .then() are scheduled with zero delay, the promise handler runs first. The current script is a macrotask. When it finishes, the event loop drains all microtasks before picking up the next macrotask (the setTimeout callback).

A More Complex Example

console.log("1: Start");

setTimeout(() => console.log("2: Timeout 1"), 0);
setTimeout(() => console.log("3: Timeout 2"), 0);

Promise.resolve()
.then(() => console.log("4: Promise 1"))
.then(() => console.log("5: Promise 2"));

Promise.resolve()
.then(() => console.log("6: Promise 3"));

console.log("7: End");

Output:

1: Start
7: End
4: Promise 1
6: Promise 3
5: Promise 2
2: Timeout 1
3: Timeout 2

Step-by-step execution:

  1. "1: Start" logs (synchronous)
  2. Two setTimeout callbacks are queued as macrotasks
  3. Two promise chains create microtasks
  4. "7: End" logs (synchronous)
  5. Current macrotask (the script) finishes
  6. Event loop drains all microtasks:
    • "4: Promise 1" logs, which schedules another microtask (.then() for Promise 2)
    • "6: Promise 3" logs
    • "5: Promise 2" logs (microtask added during microtask processing)
  7. Microtask queue is empty, event loop picks next macrotask:
    • "2: Timeout 1" logs
  8. Next macrotask:
    • "3: Timeout 2" logs

Notice step 6: microtasks created during microtask processing are also executed before any macrotask. The queue must be completely empty before moving on.

Promise Handlers Always Run Asynchronously

Even when a promise is already resolved at the moment you attach a .then() handler, the handler does not run immediately. It is always scheduled as a microtask for later execution. This guarantees consistent, predictable ordering.

Already-Resolved Promises Are Still Async

console.log("1: Before promise");

const alreadyResolved = Promise.resolve("instant");

alreadyResolved.then(value => {
console.log("2: Promise handler:", value);
});

console.log("3: After .then()");

Output:

1: Before promise
3: After .then()
2: Promise handler: instant

Even though alreadyResolved is fulfilled the moment it is created, the .then() handler does not run inline. It is placed in the microtask queue and runs after the current synchronous code completes.

Why This Design Decision Matters

If promise handlers could run synchronously for already-resolved promises, the execution order of your code would depend on whether a promise happened to be resolved or pending, making behavior unpredictable:

function getData(cache) {
if (cache.has("data")) {
// If this ran synchronously, code AFTER this function call
// would see different behavior depending on cache state
return Promise.resolve(cache.get("data"));
}
return fetch("/api/data").then(r => r.json());
}

console.log("Before");
getData(someCache).then(data => {
console.log("Got data");
});
console.log("After");

// Without the async guarantee, "Got data" might print before or after "After"
// depending on cache state. With the guarantee, "After" ALWAYS prints first.

The async guarantee ensures that "After" always logs before "Got data", regardless of whether the data came from cache or network. This consistency makes code behavior predictable.

async/await Follows the Same Rules

await on an already-resolved promise still yields to the microtask queue:

async function example() {
console.log("1: Async function start");
const value = await Promise.resolve("instant");
// Everything after await is a microtask
console.log("2: After await:", value);
}

console.log("3: Before calling async");
example();
console.log("4: After calling async");

Output:

3: Before calling async
1: Async function start
4: After calling async
2: After await: instant

The async function runs synchronously until it hits await. At that point, it suspends and the rest becomes a microtask. The caller continues to execute, and the await continuation runs after the current synchronous code finishes.

Multiple Awaits Create Multiple Microtasks

async function multiAwait() {
console.log("A1");
await Promise.resolve();
console.log("A2"); // Microtask 1
await Promise.resolve();
console.log("A3"); // Microtask 2
await Promise.resolve();
console.log("A4"); // Microtask 3
}

console.log("Start");
multiAwait();
console.log("End");

Output:

Start
A1
End
A2
A3
A4

Each await creates a new microtask checkpoint. But since no macrotasks are interleaved, all three continuations run back-to-back during microtask processing.

queueMicrotask()

The queueMicrotask() function explicitly adds a callback to the microtask queue. It is the most direct way to schedule a microtask without creating a promise.

Basic Usage

console.log("1: Start");

queueMicrotask(() => {
console.log("2: Microtask via queueMicrotask");
});

console.log("3: End");

Output:

1: Start
3: End
2: Microtask via queueMicrotask

queueMicrotask vs. Promise.resolve().then()

Both schedule microtasks, but queueMicrotask is more direct and lighter-weight:

// Using Promise: creates a Promise object just to schedule a callback
Promise.resolve().then(() => {
console.log("Promise microtask");
});

// Using queueMicrotask: directly queues the callback
queueMicrotask(() => {
console.log("Direct microtask");
});

They run at the same priority level:

Promise.resolve().then(() => console.log("Promise 1"));
queueMicrotask(() => console.log("queueMicrotask 1"));
Promise.resolve().then(() => console.log("Promise 2"));
queueMicrotask(() => console.log("queueMicrotask 2"));

Output:

Promise 1
queueMicrotask 1
Promise 2
queueMicrotask 2

They are interleaved in the order they were queued, regardless of whether they came from queueMicrotask or .then(). Both go into the same microtask queue.

queueMicrotask vs. setTimeout(..., 0)

This is the important distinction. setTimeout(..., 0) schedules a macrotask, not a microtask:

console.log("Start");

setTimeout(() => console.log("setTimeout"), 0);
queueMicrotask(() => console.log("queueMicrotask"));

console.log("End");

Output:

Start
End
queueMicrotask
setTimeout

queueMicrotask always runs before setTimeout, even if setTimeout has a delay of 0, because microtasks have priority over macrotasks.

When to Use queueMicrotask

Use it when you need to defer work until after the current synchronous code completes, but before any rendering or macrotasks:

// Batching synchronous updates
class StateManager {
#state = {};
#listeners = new Set();
#notificationPending = false;

setState(key, value) {
this.#state[key] = value;

// Don't notify on every single setState call
// Batch: schedule ONE notification via microtask
if (!this.#notificationPending) {
this.#notificationPending = true;
queueMicrotask(() => {
this.#notificationPending = false;
this.#notifyListeners();
});
}
}

#notifyListeners() {
for (const listener of this.#listeners) {
listener({ ...this.#state });
}
}

subscribe(listener) {
this.#listeners.add(listener);
}
}

const store = new StateManager();
store.subscribe(state => console.log("State updated:", state));

// These three calls happen synchronously
store.setState("name", "Alice");
store.setState("age", 30);
store.setState("role", "developer");

// Listeners are notified only ONCE, after all three changes,
// with the final state: { name: "Alice", age: 30, role: "developer" }

Cleanup After Synchronous Operations

function processItems(items) {
const results = [];

for (const item of items) {
results.push(transform(item));
}

// Schedule cleanup after current synchronous work
queueMicrotask(() => {
clearTemporaryData();
updateMetrics(results.length);
});

return results; // Returned before cleanup runs
}
warning

queueMicrotask should not be used as a general-purpose "delay" mechanism. If you need to yield to the browser for rendering or user events, use setTimeout or requestAnimationFrame instead. Microtasks run before rendering, so a long chain of microtasks can block the UI just as effectively as synchronous code.

Execution Order: Script, Microtasks, Rendering, Macrotasks

The event loop follows a specific cycle that determines when each type of work runs. Understanding this cycle explains all the ordering behavior you have seen.

The Event Loop Cycle

┌──────────────────────────────────────────────────────┐
│ EVENT LOOP │
│ │
│ 1. Execute current macrotask (script, setTimeout │
│ callback, event handler, etc.) │
│ │ │
│ ▼ │
│ 2. Drain the ENTIRE microtask queue │
│ (promise handlers, queueMicrotask, │
│ MutationObserver callbacks) │
│ └── If new microtasks are added during this │
│ phase, they are also executed before │
│ moving on │
│ │ │
│ ▼ │
│ 3. Render (if needed) │
│ - Calculate styles │
│ - Layout │
│ - Paint │
│ - requestAnimationFrame callbacks │
│ │ │
│ ▼ │
│ 4. Pick the next macrotask from the queue │
│ and go back to step 1 │
│ │
└──────────────────────────────────────────────────────┘

Complete Ordering Demonstration

console.log("1: Script start (macrotask - the script itself)");

setTimeout(() => {
console.log("2: setTimeout callback (macrotask)");

Promise.resolve().then(() => {
console.log("3: Promise inside setTimeout (microtask)");
});
}, 0);

requestAnimationFrame(() => {
console.log("4: requestAnimationFrame (before next paint)");
});

Promise.resolve().then(() => {
console.log("5: Promise 1 (microtask)");
});

queueMicrotask(() => {
console.log("6: queueMicrotask (microtask)");
});

Promise.resolve().then(() => {
console.log("7: Promise 2 (microtask)");
});

console.log("8: Script end (still in the macrotask)");

Output (typical browser):

1: Script start (macrotask - the script itself)
8: Script end (still in the macrotask)
5: Promise 1 (microtask)
6: queueMicrotask (microtask)
7: Promise 2 (microtask)
4: requestAnimationFrame (before next paint)
2: setTimeout callback (macrotask)
3: Promise inside setTimeout (microtask)

Explanation:

  1. The script itself is the first macrotask. Lines 1 and 8 log synchronously.
  2. Microtask queue is drained: Promise 1, queueMicrotask, Promise 2 (in queuing order).
  3. Browser checks if rendering is needed. requestAnimationFrame callback runs.
  4. Next macrotask: setTimeout callback logs.
  5. After setTimeout callback, its microtask queue is drained: Promise inside setTimeout logs.

Microtasks Within Macrotasks

Each macrotask gets its own microtask drain phase. Microtasks created during a macrotask run immediately after that macrotask, before the next one:

setTimeout(() => {
console.log("Timeout 1");
Promise.resolve().then(() => console.log("Micro in Timeout 1"));
}, 0);

setTimeout(() => {
console.log("Timeout 2");
Promise.resolve().then(() => console.log("Micro in Timeout 2"));
}, 0);

Output:

Timeout 1
Micro in Timeout 1
Timeout 2
Micro in Timeout 2

Each setTimeout callback is a macrotask. After each one completes, its microtasks are drained before the next macrotask runs. You never see "Timeout 1" followed by "Timeout 2" before the microtasks.

Microtasks Creating More Microtasks

When a microtask creates another microtask, the new one is also processed before any macrotask:

console.log("Start");

setTimeout(() => console.log("Timeout"), 0);

Promise.resolve().then(() => {
console.log("Micro 1");

queueMicrotask(() => {
console.log("Micro 2 (created by Micro 1)");

queueMicrotask(() => {
console.log("Micro 3 (created by Micro 2)");
});
});
});

console.log("End");

Output:

Start
End
Micro 1
Micro 2 (created by Micro 1)
Micro 3 (created by Micro 2)
Timeout

The microtask queue is drained recursively. Micro 1 adds Micro 2, which adds Micro 3. All three run before Timeout because the event loop keeps processing microtasks until the queue is empty.

Interactive Example: Tracing the Event Loop

function trace(label) {
console.log(`[${performance.now().toFixed(1)}ms] ${label}`);
}

trace("Script start");

// Macrotask
setTimeout(() => {
trace("setTimeout 0ms");

// Microtask inside macrotask
Promise.resolve().then(() => {
trace("Promise inside setTimeout");
});
}, 0);

// Another macrotask
setTimeout(() => {
trace("setTimeout 100ms");
}, 100);

// Microtasks
Promise.resolve()
.then(() => trace("Promise chain step 1"))
.then(() => trace("Promise chain step 2"))
.then(() => trace("Promise chain step 3"));

queueMicrotask(() => trace("queueMicrotask"));

trace("Script end");

Output:

[0.1ms] Script start
[0.2ms] Script end
[0.3ms] Promise chain step 1
[0.3ms] queueMicrotask
[0.4ms] Promise chain step 2
[0.4ms] Promise chain step 3
[1.5ms] setTimeout 0ms
[1.5ms] Promise inside setTimeout
[100.2ms] setTimeout 100ms

Why This Matters for Performance and UI

The microtask/macrotask distinction has direct, practical consequences for user experience, rendering performance, and application behavior.

Microtask Starvation: Blocking the UI

Because the entire microtask queue must drain before rendering or any macrotask can proceed, an endless stream of microtasks will freeze the browser completely. No rendering, no user events, no timers.

// DANGER: This freezes the browser
function infiniteMicrotasks() {
queueMicrotask(() => {
// This creates another microtask, which creates another, forever
infiniteMicrotasks();
});
}

infiniteMicrotasks(); // Browser becomes completely unresponsive

This is worse than an infinite synchronous loop because at least some browsers can detect and break infinite loops. Infinite microtask chains are harder to detect and kill.

The same problem occurs with recursive promise chains:

// Also freezes the browser
function recursivePromise() {
return Promise.resolve().then(() => recursivePromise());
}

recursivePromise(); // Never yields to rendering or macrotasks

Long Microtask Chains Delay Rendering

Even finite microtask chains can cause problems if they are long enough:

// This delays rendering until all 100,000 microtasks complete
for (let i = 0; i < 100000; i++) {
queueMicrotask(() => {
// Light work per microtask, but 100,000 of them
processItem(i);
});
}

The browser cannot paint a single frame until every one of those microtasks finishes. The user sees a frozen screen.

Using Macrotasks to Yield to the Browser

When you have heavy work that should not block the UI, break it into macrotasks with setTimeout so the browser can render between chunks:

// BAD: all microtasks, blocks rendering
async function processAllItems(items) {
for (const item of items) {
await processItem(item); // Each await creates a microtask
}
// All items processed before any rendering happens
}

// GOOD: yield to browser periodically with setTimeout
function processAllItemsYielding(items) {
let index = 0;

function processChunk() {
const chunkEnd = Math.min(index + 100, items.length);

while (index < chunkEnd) {
processItem(items[index]);
index++;
}

if (index < items.length) {
// Yield to browser: setTimeout creates a macrotask
// Browser can render and handle events before next chunk
setTimeout(processChunk, 0);
} else {
console.log("All items processed");
}
}

processChunk();
}

The scheduler.yield() Alternative (Emerging API)

A newer API designed specifically for yielding to the browser is scheduler.yield():

// Future/experimental (check browser support)
async function processWithYielding(items) {
for (let i = 0; i < items.length; i++) {
processItem(items[i]);

// Yield every 100 items
if (i % 100 === 99) {
await scheduler.yield(); // Lets browser render and handle events
}
}
}

DOM Updates and Microtask Timing

Microtasks run after DOM mutations but before the browser paints. This means DOM changes made in a microtask are visible in the next paint without an extra frame delay:

const div = document.getElementById("output");

// Synchronous DOM change
div.textContent = "Step 1";

// Microtask DOM change (happens before the browser paints)
queueMicrotask(() => {
div.textContent = "Step 2"; // User never sees "Step 1"
});

// The browser paints ONCE with "Step 2"

The user never sees "Step 1" because the microtask changes it to "Step 2" before the browser renders. Only one paint happens.

Compare with a macrotask:

const div = document.getElementById("output");

div.textContent = "Step 1";

setTimeout(() => {
div.textContent = "Step 2";
}, 0);

// The browser MAY paint "Step 1" briefly before painting "Step 2"
// because setTimeout yields to the render cycle

Practical Implications Summary

// Use microtasks (Promise, queueMicrotask) when:
// - You need code to run as soon as possible after current execution
// - You need to batch updates before the browser renders
// - You need consistent ordering with other promise-based code

queueMicrotask(() => {
// Runs before rendering, before any setTimeout
batchStateUpdate();
});

// Use macrotasks (setTimeout) when:
// - You need to yield to the browser for rendering
// - You are processing large amounts of data in chunks
// - You want user events to be handled between chunks

setTimeout(() => {
// Browser has had a chance to render and process events
processNextChunk();
}, 0);

// Use requestAnimationFrame when:
// - You need to update visuals/animations
// - You want to synchronize with the browser's paint cycle

requestAnimationFrame(() => {
// Runs right before the next paint
updateAnimation();
});

MutationObserver: A Special Microtask Source

MutationObserver callbacks are microtasks, which means they fire after DOM changes but before the browser paints:

const target = document.getElementById("container");

const observer = new MutationObserver((mutations) => {
// This is a microtask: runs before browser paints
console.log("DOM changed:", mutations.length, "mutations");

// You can make additional DOM changes here
// and they'll be included in the same paint
});

observer.observe(target, { childList: true, subtree: true });

// These synchronous DOM changes trigger the observer
target.innerHTML = "<p>New content</p>";
target.appendChild(document.createElement("div"));

console.log("Synchronous code done");

// Output:
// "Synchronous code done"
// "DOM changed: 2 mutations" ← microtask, before paint

Summary

The microtask queue is a high-priority lane within the event loop that processes promise handlers, queueMicrotask callbacks, and MutationObserver notifications before the browser can render or execute any macrotask. This priority system is what makes promise chains feel immediate and predictable, but it also means careless use of microtasks can block rendering and freeze the UI.

ConceptKey Point
MicrotasksPromise handlers, queueMicrotask, MutationObserver, await continuations
MacrotaskssetTimeout, setInterval, event handlers, I/O callbacks, requestAnimationFrame
PriorityAll microtasks drain before the next macrotask runs
Promise handlersAlways asynchronous, even for already-resolved promises, scheduled as microtasks
queueMicrotask()Direct way to schedule a microtask without creating a promise
Event loop orderScript (macrotask) → drain microtasks → render → next macrotask → drain microtasks → render → ...
Microtask recursionMicrotasks creating microtasks are processed in the same drain cycle
Starvation riskInfinite or very long microtask chains block rendering and user interaction
Yielding to browserUse setTimeout or requestAnimationFrame to let the browser render between chunks
DOM + microtasksDOM changes in microtasks are included in the next paint, user never sees intermediate states

Key rules to remember:

  • Microtasks always run before macrotasks when both are pending
  • The microtask queue is drained completely (including microtasks added during draining) before any macrotask or rendering
  • Promise handlers are always asynchronous, guaranteeing consistent execution order regardless of promise state
  • Heavy computation should use macrotasks (setTimeout) to yield to the browser, not microtasks
  • queueMicrotask is lighter than Promise.resolve().then() for scheduling microtasks
  • The execution order is always: synchronous code → microtasks → render → next macrotask