Node.js — Requests Time Out While CPU Stays Idle: a Blocked Loop
Find the synchronous operation blocking the Node.js event loop, use monitorEventLoopDelay and blocked-at to time and localize it, and move the work off the loop.
Symptoms
- Health checks time out under load while CPU utilization stays far below one core
- Latency histograms show periodic multi-second stalls affecting all requests equally
- No error is logged; requests simply queue and then complete in a burst
Possible causes
- A synchronous crypto or compression call (pbkdf2, zip of a large tree) monopolizes the loop
- JSON.parse or stringify of a multi-hundred-megabyte payload runs inline in a request handler
- A synchronous regular expression with catastrophic backtracking stalls mid-pattern
- A giant in-memory sort or shuffle of millions of rows runs on the main loop
Diagnosis steps
Measure loop delay continuously
const { monitorEventLoopDelay } = require('node:perf_hooks'); const h = monitorEventLoopDelay({ resolution: 20 }); h.enable(); setInterval(() => log({ p99: percentile(h, 99) }), 5000);Interpretation
A p99 loop delay that spikes from microseconds to seconds during incidents confirms the loop itself stalls, not the downstream systems.
Capture the blocking stack at the stall
BLOCKED_AT_THRESHOLD_MS=200 node --require blocked-at/register server.jsInterpretation
blocked-at prints the stack of whatever runs longer than the threshold, naming the exact synchronous call site.
Cross-check with per-request timing
const t = process.hrtime.bigint(); res.on('finish', () => log({ ms: Number(process.hrtime.bigint() - t) / 1e6 }));Interpretation
When even trivial endpoints show the same stall window, the delay belongs to the loop rather than to one slow handler.
Root cause
A long-running synchronous operation occupied the single JavaScript thread. Every pending callback, including health checks and unrelated request handlers, waited behind it. CPU stays low because one stalled call does not compute — it simply blocks while queued work accumulates.
Fix
Move the heavy operation off the loop: use worker_threads for CPU-bound work or async variants of crypto APIs, split giant JSON work into streamed parsing, bound regexes with input-size guards, and process large collections in batches with setImmediate between chunks.
Risk note
Sharding work with setImmediate keeps the loop responsive but lengthens total wall time. Measure both before committing to a batching strategy.
Verify the fix
Reproduce the load that triggered stalls and assert loop delay p99 stays under a fixed budget while end-to-end latency tracks the actual work cost, with health checks green throughout.
Symptom walkthrough
The paradox in the metrics is the clue: requests time out, yet CPU sits idle and downstream services report no load. Instrument the loop itself:
import { monitorEventLoopDelay } from 'node:perf_hooks';
const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();
setInterval(() => {
console.log('loop p99 ms:', percentile(histogram, 99) / 1e6);
histogram.reset();
}, 5000);
Normal services show p99 loop delay in microseconds. During an incident you will see seconds. That single number separates a blocked loop from slow dependencies: a slow database slows the affected route, a blocked loop slows everything at the same instant.
Fix in practice
Localize the culprit stack with blocked-at, then choose the right escape hatch by workload type:
BLOCKED_AT_THRESHOLD_MS=200 node --require blocked-at/register server.js
// crypto: prefer the async variant — it runs on the threadpool
crypto.pbkdf2(password, salt, iters, 32, 'sha256', cb);
// big payloads: stream the parse instead of JSON.parse on 300 MB
await pipeline(fs.createReadStream(path), jsonParseStream(), sink);
// big collections: batch with setImmediate to yield between chunks
for (let i = 0; i < rows.length; i += 50_000) {
processChunk(rows.slice(i, i + 50_000));
await new Promise((r) => setImmediate(r));
}
// regex: guard input size before matching
if (input.length > 10_000) throw new Error('input too large for regex');
For genuinely CPU-bound algorithms, worker_threads beats batching: the main loop stays at microseconds while the pool does the computing.
Verification
Re-run the exact load profile that previously produced stalls. Success has three measurements: loop delay p99 stays under your budget (say 50 ms), health checks never time out during the run, and total throughput equals or beats the pre-fix baseline because the work now overlaps instead of serializing. Keep the monitorEventLoopDelay gauge exported as a metric so a future regression of this class alarms on the loop, not on customer timeouts.