Skip to content

Microtask Starvation and Queue Priority

advanced11 min read

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

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.

Quiz
What happens when you run: function loop() { Promise.resolve().then(loop); } loop();

The Difference: Stack Overflow vs. Microtask Starvation

Stack OverflowMicrotask Starvation
CauseRecursive calls without returningMicrotasks scheduling more microtasks
ErrorRangeError: Maximum call stack size exceededNone — silent freeze
DetectionImmediate, obviousHard to detect, intermittent
Call stackGrows unboundedStays flat (each microtask completes)
RecoveryAutomatic (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.

Quiz
Which of these patterns is safe from microtask starvation?

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

  1. schedule() runs synchronously, sets count to 1, queues a microtask.
  2. console.log('Done scheduling') runs. Output: Done scheduling
  3. The script task ends. Microtask checkpoint begins.
  4. The microtask runs schedule(), increments count, queues another microtask.
  5. This repeats 999,999 times — all within the microtask checkpoint.
  6. After count reaches 1,000,000, the condition fails, no more microtasks are queued.
  7. Microtask queue is finally empty. Rendering can happen.
  8. 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

Key Rules
  1. 1Microtask starvation is silent — no error is thrown. The page freezes without any console output.
  2. 2Any microtask that schedules another microtask extends the microtask checkpoint. N microtasks chaining means N iterations before the event loop advances.
  3. 3MutationObserver + DOM modifications inside the callback = starvation risk. Always add cycle detection.
  4. 4Use macrotasks (setTimeout, MessageChannel) for loops that must yield to rendering. Microtasks never yield — they drain before the render step.
  5. 5async/await does NOT yield to rendering unless the awaited value involves actual asynchronous I/O. await Promise.resolve() is just a microtask.