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