Your Node.js API gets slow. The database looks fine. So do the downstream services. Yet p99 latency has tripled and even the health check takes 300ms.
Often the problem is the event loop itself. Node runs your JavaScript on one thread, and when something keeps that thread busy, every other request in the process waits in line. That includes the ones that do almost nothing.
That wait is called event loop lag. It's one of the cheapest metrics you can add to a Node service, and one of the most useful.
This is part 1 of a 3-part series:
- What lag is, how to measure it, and when to check it (this post)
- The common causes and how to fix them
- CPU throttling: the lag your dashboard hides Every number below comes from scripts I ran on Node 22 in a 4 vCPU Linux container. The code is in the post, so you can rerun it and get your own.
How the Node.js event loop works (in 30 seconds)
Node's event loop goes round in phases: timers (setTimeout, setInterval), pending callbacks, poll (where most I/O callbacks run), check (setImmediate) and close callbacks. After each callback, it drains the microtask queues (process.nextTick, then promise callbacks).
The part that matters: only one callback runs at a time. Node can have thousands of sockets open, but if one callback runs for 130ms, nothing else in that process runs for 130ms. Your health check included.
What is event loop lag?
Schedule a timer for 10ms and note when it actually fires. At 10ms, the loop was free. At 126ms, something held the loop for about 116ms. That difference is event loop lag (Node calls it event loop delay).
It's queueing delay, paid before a callback's own work even starts, and it lands on every request handled in that window.
How to measure event loop lag in Node.js
You could hand-roll the timer trick, but perf_hooks already has monitorEventLoopDelay: a histogram sampled by libuv, with percentiles. Pair it with event loop utilization (ELU), the fraction of time the loop spent running code instead of waiting for I/O.
// lag-metrics.js
const { monitorEventLoopDelay, performance } = require('node:perf_hooks');
const histogram = monitorEventLoopDelay({ resolution: 10 }); // sample every 10ms
histogram.enable();
let lastELU = performance.eventLoopUtilization();
setInterval(() => {
const elu = performance.eventLoopUtilization(lastELU); // delta since last report
lastELU = performance.eventLoopUtilization();
console.log({
lag_p50_ms: histogram.percentile(50) / 1e6, // values are in nanoseconds
lag_p99_ms: histogram.percentile(99) / 1e6,
lag_max_ms: histogram.max / 1e6,
elu: elu.utilization.toFixed(2), // 0 = idle, 1 = always busy
});
histogram.reset();
}, 10_000).unref();
A few gotchas:
-
Values are in nanoseconds. Divide by
1e6for milliseconds. -
There's a floor around the resolution. With
resolution: 10, an idle process reports roughly 10ms, because the histogram includes its own sampling interval. Watch for movement above that floor. -
Ignore the mean. A loop that's fine 99% of the time and freezes for 500ms every few seconds has a lovely average and unhappy users. Look at p99 and max.
On Prometheus?
prom-client'scollectDefaultMetrics()already exportsnodejs_eventloop_lag_p99_secondsand friends. You may have the data and just no panel for it.
Demo: one blocking call slows every route
Two routes. /ping does nothing. /hash hashes a password with PBKDF2, either sync or async:
// blocking-server.js
const http = require('node:http');
const crypto = require('node:crypto');
const { promisify } = require('node:util');
const pbkdf2 = promisify(crypto.pbkdf2);
const MODE = process.env.MODE || 'sync';
http.createServer(async (req, res) => {
if (req.url === '/hash') {
const key = MODE === 'sync'
? crypto.pbkdf2Sync('secret', 'salt', 200_000, 64, 'sha512') // blocks the loop
: await pbkdf2('secret', 'salt', 200_000, 64, 'sha512'); // runs on the libuv threadpool
return res.end(key.toString('hex').slice(0, 8));
}
res.end('pong');
}).listen(3000);
The load script fires 5 /hash requests, then a /ping every 10ms for a second:
// bench-ping.js (Node 18+ for global fetch)
const get = (path) => fetch('http://localhost:3000' + path).then((r) => r.text());
const lat = [];
(async () => {
await get('/ping'); // warm up
const hashes = Array.from({ length: 5 }, () => get('/hash'));
const pings = [];
for (let i = 0; i < 100; i++) {
const t = performance.now();
pings.push(get('/ping').then(() => lat.push(performance.now() - t)));
await new Promise((r) => setTimeout(r, 10));
}
await Promise.all([...hashes, ...pings]);
lat.sort((a, b) => a - b);
const p = (q) => lat[Math.floor(q * (lat.length - 1))].toFixed(1);
console.log(`/ping p50=${p(0.5)}ms p99=${p(0.99)}ms max=${p(1)}ms`);
})();
MODE=sync node blocking-server.js & # then: node bench-ping.js
MODE=async node blocking-server.js & # then: node bench-ping.js
On my run, a route that does nothing went from a p99 of 7.6ms to 734ms. The only difference was one Sync call in a different route. The async version hands the hashing to libuv's threadpool, so the main thread stays free to answer /ping.
That's the core idea of this whole series. Lag is a shared cost: whatever blocks the loop, every request pays for it.
When should you check event loop lag?
It should always be on a dashboard. These are the moments to go and look at it on purpose:
- p99 is up, but the database and downstreams look normal. The biggest signal. If no dependency is slow, the queue in front of your own code often is.
-
Fast endpoints slow down together. If
/healthor a trivial route slows at the same time as the heavy ones, something is holding the loop. -
Timeouts and dropped connections under load. A lagging loop misses socket deadlines:
ETIMEDOUT,ECONNRESET, 502s from the load balancer, Kafka consumers kicked from their group for missed heartbeats. - Health checks fail or pods restart under load, while the app "works fine" when you poke it by hand.
- After a deploy that added a dependency, a serialization step, a bigger response, or a regex on user input.
- After changing CPU limits or instance sizes, or moving clusters.
- During load tests. Record lag p99 next to throughput. Where lag starts climbing is your real per-process capacity.
- Before you scale out. If the lag comes from throttling or one blocking call, more pods won't fix it. For an HTTP API, a rough starting point: lag p99 that stays above 50 to 100ms deserves a look, and anything regularly in the hundreds of milliseconds is hurting users. Tune that to your own latency targets. A batch worker can put up with far more.
How to act on event loop lag automatically
Once you trust the number, put it to work:
-
Load shedding. When lag crosses a threshold, return
503to new requests instead of taking work you can't finish in time. On Fastify,@fastify/under-pressuredoes this withmaxEventLoopDelay:
await fastify.register(require('@fastify/under-pressure'), {
maxEventLoopDelay: 1000, // ms
maxEventLoopUtilization: 0.98,
});
- Fail readiness, not liveness. On sustained high lag, fail the readiness probe so the pod drops out of rotation and recovers. Failing liveness restarts it, throws away a warm process and dumps its traffic on the others, which usually makes things worse.
- Alert on sustained p99, not single spikes.
FAQ
What is event loop lag in Node.js?
The delay between when a callback is ready to run and when the event loop actually runs it. It grows when something keeps the main thread busy or stops the process from getting CPU time.
Is event loop lag the same as event loop delay?
Yes. Node's API calls it event loop delay (monitorEventLoopDelay). "Lag" is the common name in dashboards and libraries.
What is a good event loop lag value?
There's no universal number. As a rough starting point for an HTTP API, a p99 that stays above 50 to 100ms deserves a look, and regular spikes in the hundreds of milliseconds are hurting users.
Does async/await prevent event loop lag?
No. await only frees the loop while you wait on real I/O or a timer. CPU-heavy code inside an async function still blocks, and long promise chains can block too (part 2 shows this).
What's the difference between event loop lag and event loop utilization?
Lag is how late callbacks run. Utilization (ELU) is the share of time the loop was busy running code. High lag with high ELU means busy code. High lag with low ELU usually means the process was paused, for example by CPU throttling (part 3).
Up next
You now know what lag is and how to see it. Part 2 goes through what actually causes it: blocking JavaScript, bad regexes, sync I/O, big JSON, GC pauses and promise loops, each with a runnable example and a fix.
If you add one metric to your Node services this week, make it lag p99 next to ELU.
The numbers in this post are from one run on Node 22 in a 4 vCPU container. Yours will differ, and that's the point of running the scripts.



Top comments (1)
Pairing monitorEventLoopDelay with event loop utilization is a good call, because lag alone can look fine while the loop is nearly saturated. In practice I find it most useful to export the p99 and max as metrics and alert on those rather than the mean, since a single long synchronous task, like a big JSON.parse or a regex backtrack, hides in averages. It also helps to log the route being handled when lag spikes, so you can trace it to a handler. Are you planning to cover how worker threads compare with chunking the work in part 2?