← Concepts

阅读中文版本

Event Loop Starvation

Event loop starvation happens when long synchronous work or unbounded microtasks prevent timers, I/O, and rendering from running, making a JavaScript process unresponsive.

runtime

Definition

Event loop starvation is a condition where the event loop cannot reach pending macrotasks because the current task or the microtask queue never yields control. A long synchronous loop blocks everything. An unbounded chain of promise callbacks can starve timers and I/O even though each callback is short, because microtasks are drained completely before the next macrotask.

Context and scope

Starvation affects browsers and Node.js servers. In a browser it freezes rendering and input; on a server it delays requests and health checks. It often appears after adding CPU-heavy validation, large JSON parsing, or recursive promise scheduling to an otherwise asynchronous codebase.

Common misconceptions

  • Async code cannot block the event loop — synchronous work inside an async function still blocks
  • Promise callbacks are low priority — microtasks run before the next timer or I/O callback and can starve them
  • setImmediate always runs before I/O — it runs in a specific phase; ordering depends on the runtime and context

Anti-patterns

  • Parsing or validating a multi-megabyte payload synchronously on the request path
  • Recursively scheduling Promise.resolve().then() without a yield
  • Running CPU-bound compression or image processing on the main thread

Related concepts

Related examples

Authoritative source

https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick

Two ways to starve the loop

The first is a long synchronous task: a loop, a large sort, or a synchronous parse. Until it returns, no timer, I/O callback, or render can run.

The second is more subtle: a microtask chain. Microtasks are drained before the next macrotask. A promise that schedules another promise forever keeps the loop in the microtask phase, so timers never fire.

// ❌ Starves timers: the microtask queue never empties
function spin() {
  Promise.resolve().then(spin);
}
spin();

// ✅ Yield to the event loop between chunks
async function processInChunks(items, handle) {
  for (let i = 0; i < items.length; i += 1) {
    handle(items[i]);
    if (i % 100 === 0) {
      await new Promise((resolve) => setTimeout(resolve, 0));
    }
  }
}

Diagnosing it

In Node.js, measure event loop delay with perf_hooks.monitorEventLoopDelay(). In browsers, long tasks appear in the Performance panel; input latency and rendering gaps are the user-visible symptom. A CPU profile that shows one long stack or a flood of promise callbacks points to the cause.

When to use chunking

Chunk work when a task cannot be moved to a worker and must run on the main thread. Compared to a Web Worker or worker thread, chunking keeps shared state simple but increases total elapsed time. Compared to backpressure, chunking yields between units of work; backpressure limits how much work enters the system.

Pitfalls

Yielding with setTimeout(0) adds latency; batch work in sizes that balance responsiveness and throughput. Do not assume await yields to I/O — awaiting an already-resolved promise stays in the microtask queue. Test under realistic payload sizes; a loop that is fast in development can starve production under larger inputs.