Event loop freeze: microtask starvation

Microtasks (queueMicrotask / Promise.then) run before paints and timers. An endless microtask chain can starve rendering + input, making the UI appear frozen. Fix: chunk work and yield to the browser.

Break it: microtask storm freezes the UI

Repro: click Start, then try typing or clicking Clear while the storm runs. It often won’t respond until the storm ends (because paints/input are starved).

This demo intentionally blocks the main thread.

❌ Bug: UI freezes

Microtasks run before paints. During a microtask storm, the browser can’t reliably process clicks or typing.

Status: Invariant intact
function storm() {
  queueMicrotask(() => {
    // heavy work...
    storm(); // ❌ starves paints/input
  });
}

✅ Fix: Yield to the browser

Work continues, but we yield between chunks so typing stays responsive.

Status: Not running
function runChunk() {
  doHeavyWork();
  setTimeout(runChunk, 0); // ✅ yield
}

Detected issue

Detected issue

Status: Invariant intact
Repro runs: 0
How to trigger it
  • Click “Start microtask storm”.
  • Try typing/clicking while it’s running.
Click Start once to reproduce.
Why this breaks
Microtasks are drained before the browser can paint or run timers. If each microtask schedules another microtask, the browser can’t reach a paint step or process input. The result is a UI that looks frozen even though JavaScript is still executing.

Reference (correct mental model)

Buggy pattern

Microtasks run after the current task, before the browser can paint or process timers. They’re great for small bookkeeping — dangerous for long chains.

function storm() { queueMicrotask(() => { doHeavyWork(); storm(); // ❌ schedules endlessly in the microtask queue }); }

Correct pattern

Rule: Do heavy work in chunks and yield to the browser between chunks (setTimeout(0), requestAnimationFrame, scheduler.yield (where available), or a Worker).

function runChunk() { doHeavyWork(); setTimeout(runChunk, 0); // ✅ yield (macrotask) // or requestAnimationFrame(runChunk) }

Fix: chunk work and yield to the browser

The buggy approach keeps scheduling microtasks and starves the browser. The fixed approach yields between chunks so typing stays responsive.

Where the fix applies

The “Fixed” panel yields control between chunks, allowing the browser to paint and process input.

Key takeaways

  • Microtasks run before paint/input and before timers.
  • Endless microtask chains can starve rendering and make the UI appear frozen.
  • Fix by chunking work and yielding between chunks (setTimeout / rAF / scheduler / Worker).

Approach & tradeoffs

Approach

Make the failure mode visible with a ‘buggy storm’ that recursively schedules microtasks. Demonstrate the fix by moving the loop to a yielding mechanism (macrotask or frame).

Tradeoffs

  • setTimeout(0) yields but timing is not deterministic and can reduce throughput.
  • requestAnimationFrame aligns with paints but caps work to frame budget (great for UI).
  • For CPU-heavy work, a Web Worker avoids blocking the main thread entirely.
Common pitfall

Using microtasks as a ‘fast loop’ for heavy work. It feels quick, but it can starve paints/input and create a bad UX.

Fix: If it’s heavy or long-running, yield (macrotask/frame) or move work off-thread (Worker). Microtasks should stay small and bounded.