A Node.js service can show low database latency, healthy memory, and plenty of free connections—and still freeze for hundreds of milliseconds. When that happens, the missing signal is often the event loop.
Node.js handles many concurrent connections with a small number of threads. That efficiency depends on one contract: each JavaScript callback must finish quickly enough to give other requests a turn. Break that contract with a large JSON.stringify, synchronous compression, expensive regular expression, or CPU-heavy transformation, and one request can delay every request handled by that process.
This guide explains how to prove that the event loop is blocked, locate the code responsible, and choose the correct fix without hiding the problem behind more server instances.
The mental model: orchestration, not magic
JavaScript for a Node.js process normally runs on one event-loop thread. Network I/O is coordinated asynchronously. Some operations—including parts of filesystem, DNS, crypto, and compression work—use libuv's worker pool. Your application may also create worker threads for CPU-intensive JavaScript.
A Node.js process
Architecture flowawait does not automatically move JavaScript to another thread. It pauses the current async function while a promise is pending, but the code before and after the await still runs on the event loop. An async function containing a 300 ms synchronous loop blocks for roughly 300 ms.
app.post("/analyse", async (req, res) => {
const rows = await database.loadRows(); // Event loop can serve other work.
const result = expensiveTransform(rows); // Event loop is blocked here.
res.json(result);
});
Node.js's official guide summarizes the design constraint well: keep the work associated with each client small. The full explanation is available in Don't Block the Event Loop (or the Worker Pool).
Blocking, waiting, and saturation are different
A slow response alone does not prove event-loop blocking. Diagnose the type of delay before choosing a remedy.
The distinction prevents common mistakes. Worker threads will not make a slow PostgreSQL query faster. Increasing the database pool will not fix synchronous JSON serialization. Adding replicas may temporarily reduce traffic per process, but it leaves the blocking code and its cost in place.
Measure event-loop delay
Event-loop delay is the gap between when the loop should have been able to run a callback and when it actually ran it. Node.js provides monitorEventLoopDelay() in node:perf_hooks, returning a histogram whose values are nanoseconds.
import { monitorEventLoopDelay } from "node:perf_hooks";
const loopDelay = monitorEventLoopDelay({ resolution: 20 });
loopDelay.enable();
// Replace this adapter with your metrics client (Prometheus, Datadog, etc.).
const publishGauge = (name: string, value: number) => {
metrics.gauge(name, value);
};
setInterval(() => {
publishGauge("nodejs_event_loop_delay_p50_ms", loopDelay.percentile(50) / 1e6);
publishGauge("nodejs_event_loop_delay_p95_ms", loopDelay.percentile(95) / 1e6);
publishGauge("nodejs_event_loop_delay_p99_ms", loopDelay.percentile(99) / 1e6);
publishGauge("nodejs_event_loop_delay_max_ms", loopDelay.max / 1e6);
loopDelay.reset();
}, 10_000).unref();
The sample assumes a synchronous metrics adapter; if yours buffers asynchronously, capture the four values before resetting the histogram. Do not publish only the mean. A process that blocks for 400 ms once per minute can have an acceptable-looking average while producing terrible p99 latency. Export percentiles and maximum delay over a fixed reporting window. Keep the sampling resolution and reporting interval consistent across deployments so graphs remain comparable.
The exact API behavior and units are documented in perf_hooks.monitorEventLoopDelay().
Measure event-loop utilization
Event-loop utilization (ELU) measures the proportion of time the loop was active rather than idle. It complements delay:
- Delay asks: how late did callbacks run?
- Utilization asks: how busy was the loop?
import { performance } from "node:perf_hooks";
let previous = performance.eventLoopUtilization();
setInterval(() => {
const current = performance.eventLoopUtilization();
const interval = performance.eventLoopUtilization(current, previous);
previous = current;
publishGauge("nodejs_event_loop_utilization", interval.utilization);
}, 10_000).unref();
Always calculate interval utilization from two snapshots. A cumulative value since process startup can hide a recent spike beneath hours of idle time.
There is no universal “bad” threshold. Establish a baseline per workload and alert on sustained deviation combined with user-facing latency. Brief utilization spikes during startup or batch work may be harmless; sustained utilization near capacity with rising p95 latency is not.
Correlate loop health with request latency
Process-level loop metrics tell you that a runtime is unhealthy, not which request caused it. Attach these signals to the rest of your observability stack:
If p99 HTTP latency and p99 loop delay rise at the same moment on one instance, the runtime is implicated. If HTTP latency rises while loop delay stays flat and a database span expands, investigate the database path. If every pod shows high delay immediately after a release, compare the new code and dependency graph.
For the broader request-path method, including PostgreSQL, Redis, queues, and connection pools, use the companion guide on reducing Node.js API response time.
Find the blocking code with a CPU profile
Metrics narrow the time window. A CPU profile identifies which functions used the event-loop thread during that window. Capture profiles under representative load in staging first; production profiling should be time-bounded, access-controlled, and tested for overhead.
Look for wide frames—functions occupying a large share of samples—and unexpected repeated work. Typical discoveries include:
A flame graph shows aggregate CPU consumption, not business correctness. Trace the hot function back to its input size and route. A perfectly optimized O(n²) algorithm is still dangerous when clients control n.
Common production blockers
1. Synchronous core APIs
Avoid synchronous filesystem, child-process, compression, and expensive crypto calls in request handlers. Their names usually make the risk visible: readFileSync, execSync, pbkdf2Sync, gzipSync.
// Blocks the event loop while reading and parsing.
const config = JSON.parse(readFileSync(path, "utf8"));
// Better for request-time I/O.
const config = JSON.parse(await readFile(path, "utf8"));
Asynchronous file reading removes the I/O wait from the event loop, but JSON.parse remains synchronous. For a small config file that is fine; for an unbounded multi-megabyte payload, it is not.
2. Large JSON payloads
JSON.parse and JSON.stringify run synchronously. Bound request body size before parsing, paginate responses, select only required database fields, and stream genuinely large exports instead of constructing one huge object.
app.use(express.json({ limit: "256kb" }));
The correct limit is a product and security decision. Set explicit exceptions for endpoints that genuinely require larger inputs rather than giving the entire API an unlimited default.
3. Catastrophic regular-expression backtracking
Some nested or ambiguous patterns take exponentially longer on a carefully chosen non-matching string. This is regular-expression denial of service (ReDoS). Limit input length, simplify patterns, test adversarial cases, and prefer parsers or linear-time engines when processing untrusted input.
// Dangerous shape: nested repetition can cause extreme backtracking.
const unsafe = /(a+)+$/;
// An attacker supplies a long run of "a" followed by a non-match.
unsafe.test(`${"a".repeat(50_000)}!`);
Treat input bounds as part of the fix. Replacing one pattern does not protect future patterns from unbounded input.
4. Unbounded in-memory transformations
Multiple map, filter, sort, and object-spread passes over large arrays consume CPU and allocate memory. The resulting garbage collection can add further latency.
Push filtering, ordering, aggregation, and pagination into PostgreSQL when the database can do them efficiently. Otherwise process bounded chunks or move genuinely CPU-intensive transformations to a worker-thread pool.
5. Logging and serialization
Logging full request bodies or large nested objects adds serialization, allocation, and output pressure to the hot path. Use structured logs with a small stable field set, redact secrets, sample noisy success events, and preserve full detail for errors only when safe.
Choose the correct remedy
The decision is not “async or workers.” It is “where should this specific work execute, and what durability and latency contract does it require?”
Use worker threads for CPU-intensive JavaScript
The official node:worker_threads documentation recommends workers for CPU-intensive JavaScript, not ordinary I/O. Use a pool rather than creating one worker per request; startup and message-passing overhead can overwhelm small tasks.
// pool.ts — simplified interface around a bounded worker pool
export async function calculateRiskScore(input: RiskInput) {
if (input.transactions.length > MAX_TRANSACTIONS) {
throw new PayloadTooLargeError();
}
return riskWorkerPool.run(input, {
timeout: 2_000,
});
}
A production pool needs:
- A bounded queue with backpressure or rejection
- Per-task timeouts and cancellation behavior
- Worker error and unexpected-exit handling
- Input-size limits before data crosses the thread boundary
- Pool saturation, queue wait, execution time, and failure metrics
- Graceful shutdown that stops new work and drains safely
Worker threads protect event-loop responsiveness; they do not create unlimited CPU. A pool larger than available CPU can increase contention and tail latency. Benchmark pool size with the real container CPU quota, not only on a developer laptop.
Know the difference between worker threads and background jobs
Worker threads are in-process compute isolation. Background jobs are durable workflow isolation.
PDF generation requested from an interactive API may fit a background job better than a worker thread because the user can poll or receive a notification later. A small risk calculation needed before approving a request may fit a worker pool.
The notification workers lesson goes deeper into idempotency, retries, poison messages, and dead-letter handling for durable jobs.
Do not forget the libuv worker pool
The event loop and JavaScript worker threads are not the only queues. Async filesystem operations and selected DNS, crypto, and compression APIs can share libuv's worker pool. A flood of expensive password-hashing operations can delay unrelated filesystem work even though the event loop itself remains responsive.
Before changing UV_THREADPOOL_SIZE, first confirm saturation and bound the work. A larger pool increases concurrency but can also increase CPU contention and memory usage. Apply configuration before the process starts, test it under the deployment's CPU quota, and monitor the operation latency you intended to improve.
Load-test for tail latency
A blocking endpoint may look acceptable when tested alone. Its real damage appears when it delays unrelated lightweight endpoints on the same process.
Use a mixed workload:
Keep data, concurrency, payload sizes, instance count, CPU limits, and cache state constant between runs. Report throughput and errors alongside latency. A change that lowers event-loop delay but reduces completed requests or increases failures is not a win.
Production alerting that leads to action
Avoid an isolated “event-loop lag > 50 ms” alert copied from another system. Create a baseline and combine runtime symptoms with user impact.
Useful alert candidates include:
- HTTP p99 exceeds the service objective and p99 event-loop delay rises on the same instances.
- Event-loop utilization remains above the tested safe range for a sustained window.
- Worker-thread queue wait exceeds the synchronous request budget.
- Container CPU throttling rises while event-loop delay degrades.
- A deployment causes a statistically meaningful shift from the previous baseline.
Route alerts to a dashboard that includes instance ID, application version, top affected routes, recent deployments, CPU, garbage collection, dependency latency, and a link to the profiling runbook.
A practical incident runbook
- Confirm user impact using p95/p99 latency and error rate.
- Separate affected routes, versions, regions, and instances.
- Compare event-loop delay, utilization, process CPU, and CPU throttling.
- Check dependency spans to rule in or out database and network waiting.
- Capture a short CPU profile during representative load.
- Identify the hot function and determine its maximum input size.
- Choose optimization, partitioning, a worker pool, or a durable job.
- Add input bounds, timeouts, and saturation metrics with the fix.
- Repeat the same mixed-workload test.
- Deploy gradually and compare tail latency against the baseline.
Final thoughts
Node.js performance depends on fairness. Every callback that runs too long prevents other clients from receiving their turn.
Measure event-loop delay and utilization together. Correlate them with request traces, CPU, garbage collection, and platform throttling. Use a profile to locate the actual code, then match the remedy to the workload: optimize small work, bound untrusted inputs, use a pool for CPU-intensive JavaScript, and use durable jobs for long workflows.
The result is not just a faster endpoint. It is a service whose latency remains predictable when one customer sends a large payload, one route performs expensive work, or production traffic stops behaving like a local benchmark.