Microtask Starvation and Queue Priority
A Page That Freezes but Never Errors
Your teammate ships a feature that uses MutationObserver to synchronize two DOM trees. In testing, it works. In production, with a complex DOM, the page freezes indefinitely. No error in the console. No stack overflow. DevTools shows the page is "running" but nothing renders.
The CPU is pegged at 100%. The only fix is to kill the tab.
This is microtask starvation — and unlike stack overflow, the engine won't save you with an error.
The Mental Model
Remember the post office model: after every letter (task), the clerk processes ALL VIP items (microtasks) before touching the next letter. Now imagine a VIP customer who, every time they're served, puts another VIP request in the slot. The clerk dutifully processes each one, but the slot is never empty. Regular mail piles up. The departure board (screen) never updates. The post office is technically "working" — but nothing useful is happening.
How Starvation Happens
The event loop's microtask checkpoint drains the queue completely. If a microtask schedules another microtask, the checkpoint continues. If this happens indefinitely, the loop never proceeds to rendering or the next task.
// This freezes the page. Forever.
function starve() {
queueMicrotask(starve);
}
starve();
Compare with the macrotask version:
// This does NOT freeze the page.
function breathe() {
setTimeout(breathe, 0);
}
breathe();
The setTimeout version schedules a new task for each iteration. Between tasks, the event loop can process microtasks, render, and handle user input. The queueMicrotask version never leaves the microtask checkpoint.
The Difference: Stack Overflow vs. Microtask Starvation
| Stack Overflow | Microtask Starvation | |
|---|---|---|
| Cause | Recursive calls without returning | Microtasks scheduling more microtasks |
| Error | RangeError: Maximum call stack size exceeded | None — silent freeze |
| Detection | Immediate, obvious | Hard to detect, intermittent |
| Call stack | Grows unbounded | Stays flat (each microtask completes) |
| Recovery | Automatic (error is thrown, stack unwinds) | Manual (kill the tab) |
Microtask starvation is more dangerous because there's no safety net. The engine has no concept of "too many microtasks" — the spec says to drain the queue, so it drains the queue.
Real-World Starvation Patterns
Pattern 1: MutationObserver Ping-Pong
const observer1 = new MutationObserver(() => {
// Reacting to tree2 changes by modifying tree1
tree1.textContent = tree2.textContent + ' synced';
});
const observer2 = new MutationObserver(() => {
// Reacting to tree1 changes by modifying tree2
tree2.textContent = tree1.textContent + ' synced';
});
observer1.observe(tree2, { characterData: true, subtree: true });
observer2.observe(tree1, { characterData: true, subtree: true });
// Any change to either tree triggers an infinite loop:
// tree1 changes → observer2 fires (microtask) → modifies tree2
// → observer1 fires (microtask) → modifies tree1 → ...
tree1.textContent = 'hello';
MutationObserver callbacks are microtasks. Two observers watching each other create an infinite microtask cycle.
Pattern 2: Promise-based Polling
// Dangerous: microtask-based polling starves the event loop
async function pollForResult() {
const result = checkResult();
if (!result) {
// This schedules a microtask, not a task!
return Promise.resolve().then(pollForResult);
}
return result;
}
If checkResult() never returns a truthy value, this polls forever via microtasks. The page freezes because each .then() schedules another microtask.
The fix is trivial — use a macrotask:
// Safe: macrotask-based polling yields to the event loop
function pollForResult() {
return new Promise((resolve) => {
function check() {
const result = checkResult();
if (result) {
resolve(result);
} else {
setTimeout(check, 100); // macrotask — yields to rendering
}
}
check();
});
}
Pattern 3: Recursive Async Processing
// Dangerous: processes entire array as microtasks
async function processAll(items) {
if (items.length === 0) return;
await processItem(items[0]); // each await creates a microtask
return processAll(items.slice(1));
}
If processItem resolves synchronously (e.g., it's a cache hit), the await just queues a microtask. For a large array, this can starve the event loop because the async recursion keeps adding microtasks faster than they drain.
Production Scenario: The State Sync Library
A state management library syncs state between tabs using BroadcastChannel. Each state change triggers a broadcast, and receiving a broadcast triggers a state update:
const channel = new BroadcastChannel('state-sync');
// Tab A
store.subscribe((state) => {
channel.postMessage(state); // broadcast changes
});
// Tab B
channel.onmessage = (event) => {
store.setState(event.data); // update local state
// This triggers store.subscribe → which broadcasts → which Tab A receives → ...
};
This doesn't cause microtask starvation directly (BroadcastChannel messages are macrotasks), but with a reactive store that uses microtasks internally (like a Proxy-based store that notifies via queueMicrotask), the chain can become: state change -> microtask notification -> subscriber fires -> broadcasts -> received -> state change -> microtask notification ...
The fix: debounce broadcasts with a macrotask, and add cycle detection:
let pendingBroadcast = false;
let receivingBroadcast = false;
store.subscribe((state) => {
if (receivingBroadcast) return; // break the cycle
if (pendingBroadcast) return; // debounce
pendingBroadcast = true;
setTimeout(() => {
pendingBroadcast = false;
channel.postMessage(store.getState());
}, 0);
});
channel.onmessage = (event) => {
receivingBroadcast = true;
store.setState(event.data);
receivingBroadcast = false;
};
How to detect microtask starvation in DevTools
Microtask starvation looks different from a long task in the Performance panel. A long task shows a single block filling the timeline. Microtask starvation shows a rapid-fire sequence of tiny tasks within the "Microtask" category that never ends. In the Performance panel: (1) Record a trace during the freeze. (2) Look for a section where the "Task" bar is extremely long but consists of thousands of tiny microtask entries. (3) Expand the call tree to find the recursive scheduling pattern. The "Bottom-Up" view is useful here — sort by self-time and look for queueMicrotask, Promise.then, or MutationObserver callbacks that appear thousands of times.
Defensive Patterns
Microtask Budget
If you must use microtasks for performance but risk unbounded growth, implement a budget:
function processBatch(items, batchSize = 100) {
let processed = 0;
function processNext() {
while (processed < items.length && processed % batchSize !== 0) {
processItem(items[processed]);
processed++;
}
if (processed < items.length) {
// Yield to the event loop with a macrotask after each batch
setTimeout(processNext, 0);
}
}
processNext();
}
Cycle Detection for Observers
let mutationDepth = 0;
const MAX_MUTATION_DEPTH = 10;
const observer = new MutationObserver((mutations) => {
if (mutationDepth >= MAX_MUTATION_DEPTH) {
console.error('Mutation cycle detected — breaking loop');
return;
}
mutationDepth++;
try {
handleMutations(mutations);
} finally {
// Reset after the microtask completes via a macrotask
setTimeout(() => { mutationDepth = 0; }, 0);
}
});
Common Mistakes
| What developers do | What they should do |
|---|---|
| Assuming the browser will automatically break infinite microtask loops The HTML spec says to drain the microtask queue. The spec doesn't define a safety limit. Some browsers may eventually show a 'page unresponsive' dialog, but the engine itself won't stop. | The browser has no microtask budget or limit. An infinite microtask loop freezes the page forever without any error. |
| Using async/await for tight loops thinking await yields to rendering await Promise.resolve() is a microtask. You need a macrotask (setTimeout) or scheduler.yield() to yield to the render pipeline. | If the awaited value resolves synchronously, await is just a microtask — it doesn't yield to rendering. |
| Ignoring starvation risk with MutationObserver MutationObserver callbacks are microtasks. If your callback modifies the DOM in a way that triggers the observer again, you have an infinite microtask loop. | Always add cycle detection when MutationObserver callbacks modify observed DOM nodes. |
Challenge: Will It Freeze?
Challenge: Starvation Detection
let count = 0;
function schedule() {
count++;
if (count < 1000000) {
queueMicrotask(schedule);
}
}
schedule();
console.log('Done scheduling');
setTimeout(() => {
console.log('Timeout fired, count:', count);
}, 0);
Show Answer
This will NOT freeze permanently, but it will block for a significant time.
schedule()runs synchronously, sets count to 1, queues a microtask.console.log('Done scheduling')runs. Output: Done scheduling- The script task ends. Microtask checkpoint begins.
- The microtask runs
schedule(), increments count, queues another microtask. - This repeats 999,999 times — all within the microtask checkpoint.
- After count reaches 1,000,000, the condition fails, no more microtasks are queued.
- Microtask queue is finally empty. Rendering can happen.
- Next task: setTimeout fires. Output: Timeout fired, count: 1000000
The page is frozen during steps 3-6. No rendering, no user input. With 1 million iterations, this could take several seconds. The setTimeout doesn't fire until ALL microtasks are drained. But it does eventually complete because the loop has a termination condition.
Key insight: the danger is not that microtask loops always cause permanent freezes — it's that they block everything else until they complete, and if the termination condition has a bug, the freeze becomes permanent.
Key Rules
- 1Microtask starvation is silent — no error is thrown. The page freezes without any console output.
- 2Any microtask that schedules another microtask extends the microtask checkpoint. N microtasks chaining means N iterations before the event loop advances.
- 3MutationObserver + DOM modifications inside the callback = starvation risk. Always add cycle detection.
- 4Use macrotasks (setTimeout, MessageChannel) for loops that must yield to rendering. Microtasks never yield — they drain before the render step.
- 5async/await does NOT yield to rendering unless the awaited value involves actual asynchronous I/O. await Promise.resolve() is just a microtask.