A Node.js application can handle many concurrent requests, but that does not mean every request will respond quickly. A server may have enough memory and available connections yet still experience slow responses because its event loop cannot process callbacks on time.
This often happens when JavaScript performs expensive calculations, processes large data structures, or runs synchronous operations that block the main thread. While that work is running, other callbacks must wait, even when those callbacks belong to unrelated requests.
Event loop lag helps developers identify this delay. By measuring how late the event loop executes scheduled work, you can detect responsiveness problems before they become widespread production issues.
This article explains how the Node.js event loop works, how to measure event loop lag, how to identify common causes, and how to reduce delays in production applications.
What Is the Node.js Event Loop?
Node.js uses an event loop to coordinate JavaScript execution and asynchronous operations. It allows an application to handle operations such as network requests, timers, and file system callbacks without creating a separate JavaScript thread for every request.
When an asynchronous operation completes, its callback can run when the event loop reaches the appropriate stage and the JavaScript thread is available.
Consider this example:
const http = require('node:http');
const server = http.createServer((req, res) => {
res.end('Request completed');
});
server.listen(3000, () => {
console.log('Server listening on port 3000');
});
The server can accept and process multiple requests without waiting for each client to finish before accepting another.
However, JavaScript callbacks generally execute on the main thread one at a time. If one callback performs a long-running calculation, other JavaScript callbacks must wait for that work to finish.
For example:
function expensiveCalculation() {
let result = 0;
for (let i = 0; i < 5_000_000_000; i++) {
result += i % 10;
}
return result;
}
Calling this function directly inside a request handler can block the event loop for a significant amount of time, depending on the environment.
During that period, Node.js cannot promptly execute other JavaScript callbacks on the same event loop. Incoming connections may still be managed by lower-level components, but application-level request processing and response callbacks can be delayed.
This is one reason a single CPU-intensive operation can affect many users simultaneously.
What Is Event Loop Lag?
Event loop lag measures how late scheduled JavaScript work executes compared with when it was expected to run.
Suppose an application schedules a callback to run after 100 milliseconds. If the callback actually runs after 250 milliseconds, the observed delay is approximately 150 milliseconds.
This delay does not necessarily mean that the timer itself is inaccurate. The event loop may have been busy executing another callback or handling a large amount of work.
Event loop lag can increase because of:
- CPU-intensive JavaScript.
- Large synchronous JSON operations.
- Synchronous file system calls.
- Excessive garbage collection activity.
- Large batches of callbacks or promise continuations.
- CPU contention from other processes.
- Native operations that consume substantial execution time.
Event loop lag is different from request duration. A request can be slow because it is waiting for a database or an external API even when the event loop remains responsive.
For that reason, monitor event loop lag alongside request latency, CPU usage, memory usage, and dependency timings.
Step 1: Measure Event Loop Delay with Node.js
Node.js provides the monitorEventLoopDelay() function through the node:perf_hooks module.
This API uses a histogram to measure event loop delay over time.
const {
monitorEventLoopDelay
} = require('node:perf_hooks');
const histogram = monitorEventLoopDelay({
resolution: 20
});
histogram.enable();
setInterval(() => {
console.log({
minMs: histogram.min / 1e6,
maxMs: histogram.max / 1e6,
meanMs: histogram.mean / 1e6,
p99Ms: histogram.percentile(99) / 1e6
});
histogram.reset();
}, 10_000);
The resolution option specifies the sampling interval in milliseconds. In this example, the histogram samples at a 20-millisecond resolution.
The API reports delay values in nanoseconds, so dividing by one million converts them to milliseconds.
The output includes several useful measurements:
minMs shows the minimum observed delay in the interval.maxMs shows the maximum observed delay.meanMs shows the average delay.p99Ms shows the 99th percentile delay.
The 99th percentile is particularly useful because an average can hide occasional long pauses. A high p99 value indicates that a portion of observations experienced substantially greater delays than most others.
The example resets the histogram after each reporting interval. This makes the output easier to interpret as a series of short observation windows.
In a production monitoring system, export these values as metrics instead of relying only on console output. Also ensure that the sampling and reporting work itself remains lightweight.
Step 2: Use Event Loop Utilization
Event loop delay tells you how late callbacks execute. Event loop utilization (ELU) provides another useful signal by measuring how much time the event loop spends active compared with its available time.
Node.js exposes this measurement through performance.eventLoopUtilization().
const {
performance
} = require('node:perf_hooks');
let previous = performance.eventLoopUtilization();
setInterval(() => {
const current = performance.eventLoopUtilization();
const interval = performance.eventLoopUtilization(
current,
previous
);
console.log({
eventLoopUtilization: interval.utilization
});
previous = current;
}, 10_000);
The utilization value ranges from 0 to 1. A value closer to 1 indicates that the event loop spent most of the measured interval active.
High utilization combined with high event loop delay can indicate that JavaScript or other event-loop work is consuming too much time.
However, utilization alone does not prove that a specific function is responsible. A busy event loop may be doing useful work, and delay can also be influenced by scheduling and system conditions.
Use these metrics to identify suspicious time windows, then investigate the code and operations executed during those periods.
Step 3: Identify Blocking Operations
One of the most common causes of event loop lag is synchronous work performed inside a request handler.
Consider this example:
const fs = require('node:fs');
const http = require('node:http');
const server = http.createServer((req, res) => {
const data = fs.readFileSync('./large-file.json');
res.writeHead(200, {
'Content-Type': 'application/json'
});
res.end(data);
});
server.listen(3000);
The readFileSync() call blocks JavaScript execution until the file read completes. While it is running, other JavaScript callbacks on that thread must wait.
An asynchronous version allows Node.js to perform the file operation without synchronously blocking JavaScript while waiting for the result:
const fs = require('node:fs/promises');
const http = require('node:http');
const server = http.createServer(async (req, res) => {
try {
const data = await fs.readFile('./large-file.json');
res.writeHead(200, {
'Content-Type': 'application/json'
});
res.end(data);
} catch (error) {
console.error('File read failed:', error);
res.writeHead(500, {
'Content-Type': 'text/plain'
});
res.end('Internal server error');
}
});
server.listen(3000);
This version does not synchronously block the event loop while waiting for the file read. However, reading the entire file into memory can still consume substantial memory, and other synchronous processing can still delay requests.
For large files, consider streaming the data instead of loading it all into memory.
Also, asynchronous APIs do not automatically make CPU-heavy JavaScript non-blocking. An expensive calculation performed after an await still runs on the JavaScript thread unless it is moved to another execution context.
Step 4: Move CPU-Intensive Work to Worker Threads
When an operation requires substantial CPU time, using an asynchronous API is not always enough. The calculation itself may block the event loop.
Node.js provides worker threads for CPU-intensive JavaScript tasks. A worker runs JavaScript in a separate thread, allowing the main event loop to continue handling other work.
A simplified worker implementation can look like this.
Create calculation-worker.js:
const {
parentPort,
workerData
} = require('node:worker_threads');
function calculate(limit) {
let result = 0;
for (let i = 0; i < limit; i++) {
result += i % 10;
}
return result;
}
parentPort.postMessage(
calculate(workerData.limit)
);
Create app.js:
const {
Worker
} = require('node:worker_threads');
function runCalculation(limit) {
return new Promise((resolve, reject) => {
const worker = new Worker(
'./calculation-worker.js',
{
workerData: { limit }
}
);
worker.once('message', resolve);
worker.once('error', reject);
worker.once('exit', (code) => {
if (code !== 0) {
reject(
new Error(`Worker stopped with code ${code}`)
);
}
});
});
}
runCalculation(5_000_000)
.then((result) => {
console.log('Result:', result);
})
.catch((error) => {
console.error('Calculation failed:', error);
});
This example moves the calculation away from the main JavaScript thread. The main thread can continue processing other callbacks while the worker performs the calculation.
For a real application, avoid creating a new worker for every small operation. Worker startup has overhead. A worker pool can reuse a limited number of workers and control concurrency.
Worker threads are best suited to CPU-intensive JavaScript. For ordinary network and file operations, Node.js asynchronous APIs are usually the more appropriate choice.
Step 5: Investigate Large JSON Operations
Large JSON payloads can cause event loop delays even when the application uses asynchronous networking.
For example:
const parsed = JSON.parse(largeJsonString);
Parsing a large JSON string is synchronous. Similarly, JSON.stringify() must process the value before returning the serialized string.
If these operations handle large objects, they can consume substantial CPU time and temporarily block other JavaScript callbacks.
Possible improvements include:
- Limit request body sizes.
- Avoid repeatedly parsing or serializing the same data.
- Process large data sets in smaller chunks where practical.
- Stream data when the format and application design permit it.
- Move expensive transformations to worker threads.
- Review whether the application needs every field in a large payload.
Breaking work into smaller chunks can improve responsiveness, but it does not necessarily reduce total CPU time. The best solution depends on the size of the data and the application's response-time requirements.
Step 6: Check Garbage Collection and Memory Pressure
Garbage collection reclaims JavaScript objects that are no longer reachable. However, garbage collection itself consumes processing time, and large heaps can contribute to longer pauses.
If event loop lag rises at the same time as memory usage increases, investigate whether the application is retaining unnecessary objects or creating excessive temporary allocations.
Useful measurements include:
heapUsed and heapTotal.- Process RSS.
- External memory.
- Event loop delay percentiles.
- Garbage collection activity.
For controlled diagnostics, Node.js can expose garbage collection information through supported runtime profiling and tracing options. Use these tools to correlate pauses with memory activity rather than assuming that every delay is caused by garbage collection.
Do not increase the heap limit automatically as the first response. A larger heap may delay memory pressure, but it can also allow the application to consume more memory without correcting the underlying allocation pattern.
Step 7: Add Event Loop Metrics to Production Monitoring
Logging event loop delay is useful during development, but production applications benefit from continuous monitoring.
Export event loop delay and utilization as metrics that your monitoring platform can collect. Compare them with request latency, request volume, CPU usage, memory consumption, and dependency response times.
For example, an application might show the following patterns:
| Observation | Possible explanation |
|---|
| High event loop delay and high CPU usage | CPU-intensive JavaScript or excessive processing |
| High event loop delay and growing heap usage | Memory pressure, large allocations, or garbage collection |
| Slow requests but normal event loop delay | Database, network, or external service latency |
| High event loop utilization with normal delay | Sustained work that may still be completing on time |
| RSS grows while heap usage remains stable | Native allocations, buffers, or memory fragmentation |
These are investigation clues, not definitive diagnoses. Use profiling and request traces to determine what is happening in the application.
Choose alert thresholds based on your service's latency requirements and normal behavior. A brief spike may be harmless, while repeated delays that affect request latency may require immediate investigation.
Common Mistakes to Avoid
Measuring only average delay
An average can hide short but severe pauses. Monitor percentiles and maximum values alongside the mean.
Assuming asynchronous code cannot block
An asynchronous function can still perform synchronous CPU-intensive work. The event loop remains blocked while that JavaScript executes.
Moving every operation to worker threads
Worker threads introduce communication and scheduling overhead. Use them for work that benefits from parallel execution, and use asynchronous APIs for ordinary I/O.
Increasing server capacity without investigating
Adding more application instances may reduce the effect of a bottleneck, but it does not necessarily fix blocking work inside each process. Measure event loop behavior before deciding whether to scale horizontally or vertically.
Ignoring external memory
A stable JavaScript heap does not guarantee stable process memory. Buffers and native allocations can increase RSS without a corresponding rise in heapUsed.
Conclusion
Event loop lag is a useful indicator of whether a Node.js application can execute callbacks promptly. High delay can result from synchronous I/O, CPU-intensive JavaScript, large JSON operations, memory pressure, or excessive callback processing.
Start by measuring event loop delay and utilization. Correlate those metrics with request latency, CPU usage, and memory consumption. Then use profiling and controlled tests to identify the operation responsible.
Use asynchronous APIs for I/O, worker threads for CPU-intensive JavaScript, and sensible limits for large payloads and in-memory data. These changes help keep the event loop responsive and prevent one expensive operation from delaying unrelated requests.
Join the conversation! Your thoughts help the community grow.