A Node.js application can have low CPU usage and still respond slowly to requests. An API endpoint that normally responds in milliseconds may suddenly take several seconds, even though the server has enough memory and the database is working normally.
One possible cause is event loop lag.
Node.js executes JavaScript callbacks on an event loop. When synchronous code or another CPU-intensive operation occupies the JavaScript thread for too long, other callbacks must wait. Incoming requests, timers, and completed asynchronous operations may all experience delays.
This problem can be difficult to diagnose because asynchronous programming does not automatically prevent blocking. A function that performs expensive calculations synchronously can delay the entire event loop, even when the rest of the application uses promises and async/await.
In this article, we will measure event loop lag in Node.js, identify common causes, and use practical techniques to investigate and reduce delays.
What Is Event Loop Lag?
Event loop lag is the delay between when an event loop callback could reasonably run and when it actually runs.
Node.js uses an event loop to coordinate JavaScript execution and asynchronous operations. When the JavaScript thread is busy, callbacks that are ready to execute must wait until the thread becomes available.
Consider this example:
console.log("Request started");
const start = Date.now();
while (Date.now() - start < 3000) {
// Simulate synchronous CPU-intensive work.
}
console.log("Request completed");
The loop occupies the JavaScript thread for approximately three seconds. During that time, other JavaScript callbacks cannot run on that thread.
A timer scheduled by another part of the application may expire during those three seconds, but its callback cannot execute until the thread becomes available.
This is one reason event loop lag can affect many requests at once. A single blocking operation can delay unrelated work handled by the same Node.js event loop.
Why Event Loop Lag Matters
Event loop lag can affect more than response time.
An application experiencing sustained lag may show:
- Increased API latency.
- Delayed timer callbacks.
- Slow processing of incoming connections.
- Delayed handling of completed asynchronous operations.
- Missed application-level timing expectations.
- Reduced throughput under load.
The important distinction is that event loop lag describes scheduling delay, not necessarily the total duration of an operation.
For example, a database query might take two seconds because the database is slow. That does not automatically mean the Node.js event loop is blocked.
If the query is handled asynchronously, the JavaScript thread can continue processing other work while waiting for the database response. However, synchronous processing after the query completes may still introduce event loop lag.
Measure event loop lag alongside request latency, CPU usage, database latency, and other application metrics to understand the actual bottleneck.
Step 1: Measure Event Loop Delay with Node.js
Node.js provides the monitorEventLoopDelay() function through the node:perf_hooks module.
It records event loop delay measurements using a timer-based sampling mechanism. This provides a useful way to observe scheduling delays over time.
Create a file named event-loop-monitor.js:
const {
monitorEventLoopDelay,
performance
} = require("node:perf_hooks");
const histogram = monitorEventLoopDelay({
resolution: 20
});
histogram.enable();
const startedAt = performance.now();
const reportInterval = setInterval(() => {
const elapsedSeconds =
(performance.now() - startedAt) / 1000;
console.log({
elapsedSeconds: elapsedSeconds.toFixed(1),
minMs: (histogram.min / 1e6).toFixed(2),
maxMs: (histogram.max / 1e6).toFixed(2),
meanMs: (histogram.mean / 1e6).toFixed(2),
p99Ms: (histogram.percentile(99) / 1e6).toFixed(2)
});
histogram.reset();
}, 10000);
function shutdown() {
clearInterval(reportInterval);
histogram.disable();
}
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
Run the monitor with:
node event-loop-monitor.js
The resolution option specifies the sampling resolution in milliseconds. A value of 20 requests sampling at approximately that interval.
The histogram reports measurements in nanoseconds, so dividing by one million converts them to milliseconds.
The example resets the histogram after each report, which makes the output useful for observing changes between reporting intervals.
The main fields are:
minMs: Minimum measured event loop delay during the interval.maxMs: Maximum measured event loop delay.meanMs: Average measured delay.p99Ms: The 99th percentile of the measured delay.
The 99th percentile helps show whether a small number of large delays are affecting the application. A low average can hide occasional severe stalls.
These values are measurements, not a guarantee that every request experiences the same delay. Sampling resolution, workload, operating system scheduling, and runtime behavior can affect the results.
Step 2: Reproduce Event Loop Lag
To confirm that the monitoring code detects blocking work, create a test that deliberately occupies the JavaScript thread.
Add this function to a separate test script:
function blockEventLoop(durationMs) {
const endTime = Date.now() + durationMs;
while (Date.now() < endTime) {
// Deliberately block the JavaScript thread.
}
}
setTimeout(() => {
console.log("Blocking operation started");
blockEventLoop(2000);
console.log("Blocking operation finished");
}, 5000);
Run the test while monitoring event loop delay.
The blocking function occupies the JavaScript thread for approximately two seconds. During that period, the monitor's timer callback cannot run normally, so the measured delay should increase.
This is a diagnostic example only. Do not use busy loops like this in application request handlers.
When testing the monitor, run the blocking code and the measurement code in the same Node.js process. A separate process will not measure the blocking behavior of the application process.
Step 3: Find Synchronous Operations
One of the most common causes of event loop lag is synchronous work performed inside a request handler.
Examples include:
- Large JSON serialization or parsing operations.
- Expensive regular expressions.
- Large array transformations.
- Synchronous file system operations.
- CPU-intensive cryptographic or compression work.
- Large loops over in-memory data.
For example:
const fs = require("node:fs");
function loadConfiguration() {
return fs.readFileSync(
"./large-config.json",
"utf8"
);
}
The readFileSync() call blocks the JavaScript thread until the file has been read.
For work that can be performed asynchronously, use the asynchronous API:
const fs = require("node:fs/promises");
async function loadConfiguration() {
return fs.readFile(
"./large-config.json",
"utf8"
);
}
The asynchronous version allows the event loop to handle other work while the file operation is pending.
However, asynchronous file reading does not make subsequent CPU-intensive parsing or transformation work free. If the application parses a very large document or performs expensive calculations afterward, that work can still block the JavaScript thread.
Use profiling to determine where time is actually spent rather than replacing every synchronous operation without considering its purpose.
Step 4: Investigate Large JSON Operations
JSON processing can become expensive when an application handles large payloads.
Consider this example:
app.post("/process", (req, res) => {
const payload = JSON.stringify(req.body);
const result = JSON.parse(payload);
res.json({
processed: true,
itemCount: Array.isArray(result.items)
? result.items.length
: 0
});
});
For a small payload, the processing cost may be insignificant. For a very large object, serialization and parsing can consume enough CPU time to delay other requests.
The extra serialization and parsing in this example are also unnecessary if the application only needs to inspect the existing object.
A simpler implementation is:
app.post("/process", (req, res) => {
const itemCount = Array.isArray(req.body.items)
? req.body.items.length
: 0;
res.json({
processed: true,
itemCount
});
});
This avoids an unnecessary serialization-and-parsing round trip.
For genuinely large payloads, consider request size limits, streaming formats where supported, and moving expensive processing to a worker thread or another service.
The correct approach depends on payload size, response requirements, and the processing logic.
Step 5: Use the Node.js Profiler to Find CPU Hotspots
Event loop monitoring tells you that delays are occurring, but it does not identify the exact function responsible.
The next step is to collect CPU profiling data.
For a controlled reproduction, start Node.js with CPU profiling enabled:
node --cpu-prof app.js
After the process exits, Node.js writes a CPU profile file that can be opened in Chrome DevTools.
The profile helps identify functions that consume significant CPU time.
Look for functions that repeatedly appear in the profile, particularly those associated with:
- Large loops.
- Serialization and parsing.
- Data transformation.
- Complex regular expressions.
- Repeated calculations.
- Synchronous compression or encryption.
CPU profiles are especially useful when event loop delay increases during periods of high application activity.
A profile collected under a representative workload is more informative than one collected while the application is idle.
Be aware that profiling adds some overhead. For production diagnosis, evaluate the impact and use a controlled profiling window.
Step 6: Move CPU-Intensive Work to Worker Threads
When a task requires substantial JavaScript computation, worker threads can move that work off the main JavaScript thread.
For example, a CPU-intensive calculation can run in a separate worker rather than blocking the request-handling thread.
Create calculation-worker.js:
const {
parentPort,
workerData
} = require("node:worker_threads");
function calculateTotal(values) {
let total = 0;
for (const value of values) {
total += Math.sqrt(value);
}
return total;
}
const result = calculateTotal(workerData.values);
parentPort.postMessage(result);
Then create a file that starts the worker:
const {
Worker
} = require("node:worker_threads");
function calculateInWorker(values) {
return new Promise((resolve, reject) => {
const worker = new Worker(
"./calculation-worker.js",
{
workerData: {
values
}
}
);
worker.once("message", resolve);
worker.once("error", reject);
worker.once("exit", (code) => {
if (code !== 0) {
reject(
new Error(
`Worker stopped with exit code ${code}`
)
);
}
});
});
}
async function main() {
const values = Array.from(
{ length: 100000 },
(_, index) => index + 1
);
const result = await calculateInWorker(values);
console.log(result);
}
main().catch(console.error);
This example demonstrates the basic worker-thread mechanism. A production implementation should also consider worker reuse, cancellation, timeouts, task limits, and how failures are handled.
Creating a new worker for every small task can introduce unnecessary overhead. For applications that process many jobs, a bounded worker pool is often more appropriate.
Worker threads help with CPU-intensive JavaScript tasks. They are not automatically the best solution for ordinary network or database operations, which should generally use asynchronous APIs.
Step 7: Check Whether the Problem Is Actually Event Loop Lag
High request latency can have several causes, and they require different solutions.
| Symptom | Possible cause | What to investigate |
|---|
| Event loop delay rises sharply | Blocking JavaScript or CPU-intensive work | CPU profiles and synchronous operations |
| Request latency rises but event loop delay stays low | Slow database or external API | Dependency latency and connection pools |
| RSS grows while heap usage stays relatively stable | Native allocations or buffer usage | External memory and process-level metrics |
| CPU usage is high across the process | Heavy computation or excessive work | CPU profiles and workload volume |
| Timer callbacks run late during traffic spikes | Event loop contention | Callback duration and request processing |
| Delay rises with large payloads | Expensive parsing or serialization | Payload size and processing time |
These patterns are diagnostic clues rather than definitive proof. For example, a native operation may consume CPU while the JavaScript thread is blocked, and a slow external dependency can coexist with event loop problems.
Correlate measurements over the same time window. A single metric rarely provides enough information to identify the root cause.
Step 8: Set Useful Monitoring Alerts
A production application should monitor event loop delay alongside request latency and resource usage.
Useful metrics include:
- Event loop delay percentiles.
- Request latency percentiles.
- CPU usage.
- Memory usage.
- Request throughput.
- Error rate.
- Database and external API latency.
- Queue depth for background processing.
Choose alert thresholds based on the application's service-level objectives. A latency-sensitive API may require a much lower event loop delay than a background worker that processes batches.
Avoid alerting on every brief delay. Short spikes may occur during normal garbage collection or other runtime activity. Focus on sustained or repeated delays that correlate with user-visible problems.
If the application has multiple Node.js processes or worker instances, monitor each process separately. A single overloaded instance can cause problems even when the overall service averages look healthy.
Common Mistakes to Avoid
- Assuming every slow request is caused by the event loop.
- Measuring only average delay and ignoring high percentiles.
- Using synchronous operations in request handlers without considering their impact.
- Replacing asynchronous I/O with worker threads unnecessarily.
- Creating a new worker for every small computation.
- Assuming
async and await automatically make CPU-intensive work nonblocking. - Capturing profiles without reproducing the workload that causes the problem.
- Ignoring the overhead and security implications of diagnostic tools.
Best Practices for Reducing Event Loop Lag
- Measure event loop delay before changing application code.
- Correlate event loop metrics with request latency and CPU usage.
- Avoid long-running synchronous operations in request handlers.
- Keep payload sizes within reasonable limits.
- Remove unnecessary JSON serialization and repeated calculations.
- Use worker threads or a worker pool for CPU-intensive JavaScript work.
- Profile the application under a representative workload.
- Monitor each process in multi-instance deployments.
- Establish alert thresholds based on actual service requirements.
- Repeat performance tests after making changes to verify the improvement.
Summary
Event loop lag occurs when callbacks cannot run when expected because the Node.js event loop is busy or otherwise delayed. It can increase API latency, delay timers, and reduce throughput across an application.
Start by measuring delay with monitorEventLoopDelay(). Reproduce the problem under a controlled workload, inspect CPU profiles, and identify whether synchronous JavaScript, large payloads, or CPU-intensive calculations are responsible.
Move suitable CPU-intensive tasks to worker threads, use asynchronous APIs for I/O, and monitor event loop delay alongside request and dependency metrics.
With consistent measurement and targeted profiling, developers can identify the actual cause of event loop lag and improve Node.js responsiveness without making unnecessary changes to the application architecture.
Join the conversation! Your thoughts help the community grow.