TL;DR / When microtasks or high-priority work continuously queues more of itself, lower-priority tasks (including rendering) never get a chance to run, freezing the UI.

How It Works

   Normal flow               Starvation

 ┌─────────────┐          ┌─────────────┐
 │  Microtask  │          │  Microtask  │
 └─────────────┘          └─────────────┘
        │                        │
        ↓                        ↓
 ┌─────────────┐          ┌─────────────┐  endless
 │  Macrotask  │          │  Microtask  │  loop!
 └─────────────┘          └─────────────┘
        │                        │
        ↓                        ↓
 ┌─────────────┐          ┌─────────────┐
 │   Render    │          │  Microtask  │
 └─────────────┘          └─────────────┘

                          Macrotask NEVER runs
                          Render NEVER runs
                          UI frozen!

Edit diagram

Task starvation occurs when a category of work monopolizes the event loop, preventing other categories from executing. The most common form in browsers is microtask starvation: microtasks that queue additional microtasks create a drain cycle that never completes, blocking macrotasks, rendering, and user interaction indefinitely.

Microtask Starvation

The event loop contract is: drain all microtasks before processing the next macrotask or rendering. This means if a microtask queues another microtask, the loop stays in the microtask drain phase. If that pattern repeats indefinitely, the loop never advances.

function starve() {
  queueMicrotask(starve); // Queues itself — infinite microtask loop
}
starve();
// The browser tab is now frozen.

This is a deliberate infinite loop, but starvation often emerges from less obvious patterns. A recursive promise chain that re-queues itself, a MutationObserver that modifies the DOM it is observing (triggering another observation), or a library that processes a queue of items using Promise.resolve().then() for each item can all cause microtask starvation when the queue is large enough.

Render Starvation

Even without infinite microtasks, a long-running synchronous macrotask or a burst of microtasks can block rendering. The browser targets 60fps (one frame every 16.6ms). If the main thread is occupied for 100ms, the user sees a visible stutter — six frames dropped. If it is occupied for 1000ms, the browser may show a "page unresponsive" dialog.

React's concurrent mode addresses render starvation through time slicing: the reconciler breaks work into small chunks and yields to the browser between them, ensuring rendering and input can interleave with component updates.

Macrotask Starvation

Less commonly, macrotask starvation occurs when a high-volume producer of microtasks (like a WebSocket receiving rapid messages, each processed via Promise.then) continuously adds microtask work between macrotasks. Each macrotask completes, but the subsequent microtask drain takes so long that the next macrotask is delayed indefinitely.

This can cause setTimeout callbacks to fire much later than expected, setInterval to drift, and event handlers to queue up without processing.

Detecting Starvation

The Long Tasks API (PerformanceObserver with entryTypes: ['longtask']) reports tasks that block the main thread for more than 50ms. The Interaction to Next Paint (INP) metric captures how starvation affects user-perceived responsiveness.

Chrome DevTools' Performance tab is the most direct diagnostic tool. A starvation scenario shows up as a long continuous block in the Main thread flame chart with no gaps for rendering.

Prevention Strategies

Break work into chunks using setTimeout. Each setTimeout callback is a new macrotask, giving the browser a chance to render between chunks:

function processChunk(items, index) {
  const end = Math.min(index + 100, items.length);
  for (let i = index; i < end; i++) process(items[i]);
  if (end < items.length) setTimeout(() => processChunk(items, end), 0);
}

Use requestIdleCallback for low-priority work. This API schedules work during idle periods when the browser has no pending tasks or rendering work.

Use scheduler.yield() (Scheduler API) or scheduler.postTask(). These emerging APIs provide explicit priority levels (user-blocking, user-visible, background) and cooperative yielding, giving the browser fine-grained control over task scheduling.

Avoid recursive microtasks. When processing a list of items asynchronously, batch them into a single microtask or use macrotask scheduling. If each item generates a promise that queues work for the next item, the microtask queue never drains.

Web Workers for CPU-intensive work. Move computation off the main thread entirely. The event loop on the main thread stays responsive, and results are communicated back via postMessage (a macrotask).

Gotchas

  • Promise.all does not cause starvation because the promises resolve independently. Starvation comes from chaining — each .then() scheduling the next piece of work as a microtask.
  • async/await loops like for (const item of items) { await process(item); } schedule each iteration as a microtask continuation, but they yield between iterations. This usually does not cause starvation unless process itself spawns many microtasks.
  • requestAnimationFrame is not a solution for starvation — it runs during the render phase, and if the event loop never reaches the render phase due to microtask starvation, rAF callbacks never execute.
  • Starvation is not the same as a memory leak. The event loop is busy, not accumulating data. However, starvation can mask leaks if work queues grow unbounded while the loop is blocked.
  • React's startTransition prevents UI starvation by marking state updates as non-urgent, allowing the reconciler to interrupt and yield to higher-priority work like user input.