Measure Node Event Loop Delay Before Blaming the CPU
Track lag between scheduled and actual timer execution, correlate spikes with request logs, and find the synchronous code responsible.
Task
Instrument event loop lag, capture the worst offenders with correlated timing, and remove the blocking code.
When to use this
Latency p99 spikes to several seconds although CPU sits at 30 percent. The dashboards show nothing wrong, because nothing measures the loop being blocked.
Prerequisites
- Node.js 16 or newer (perf_hooks monitorEventLoopDelay)
- Access to structured request logs with timestamps
- Permission to deploy an instrumentation patch
Steps
1. Enable the built-in event loop delay histogram.
// instrument.js -- import FIRST, before anything else
import { monitorEventLoopDelay } from 'node:perf_hooks';
const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
setInterval(() => {
const spike = h.percentile(99) / 1e6; // ms
if (spike > 100) {
console.error(JSON.stringify({
evt: 'loop-lag', p99ms: Math.round(spike),
mean: Math.round(h.mean / 1e6), at: new Date().toISOString(),
}));
}
h.reset();
}, 5000).unref();
Expected output: Log lines appear only when p99 lag exceeds 100 ms, each stamped with the moment the loop was blocked.
2. Correlate lag spikes with request activity in the same window.
grep "loop-lag" /var/log/app/app.log | jq -r '"\(.at) p99=\(.p99ms)ms"' | tail -5; grep "loop-lag" /var/log/app/app.log | jq -r .at | while read t; do jq -r --arg a "$t" 'select(.at >= $a and .at <= ($a + "0.2s")) | .route' requests.json 2>/dev/null | sort | uniq -c | sort -rn | head -3; doneExpected output: Routes active during each spike window are ranked; a report-generation route appearing in every spike is the prime suspect.
3. Confirm with a CPU profile sample captured during a spike.
kill -USR1 $(pgrep -f "node dist/server") 2>/dev/null; sleep 10; node --prof-process --preprocess -j isolate-*.log 2>/dev/null | jq -r '.code[].fn' 2>/dev/null | head -8 || node --prof-process isolate-*.log | grep -A3 "[Summary]"Expected output: The hottest frames in the sampled profile print; a single sync function such as renderReport dominating ticks confirms the blocker.
4. Move the blocking work off the loop.
// before: 900ms of synchronous JSON.stringify + template render
app.get('/report', (req, res) => {
res.send(renderReport(buildReport(req.query)));
});
// after: chunk to the worker_threads pool, stream the result
import { Worker } from 'node:worker_threads';
app.get('/report', (req, res) => {
const w = new Worker('./report-worker.js', { workerData: req.query });
res.setHeader('content-type', 'text/html');
w.on('message', (chunk) => res.write(chunk));
w.on('exit', () => res.end());
});
Expected output: The p99 lag line disappears from the log during report requests, and request latency for other routes stops spiking alongside them.
5. Keep the histogram as a permanent guardrail metric.
// metrics endpoint addition
import { monitorEventLoopDelay } from 'node:perf_hooks';
const lag = monitorEventLoopDelay({ resolution: 20 });
lag.enable();
setInterval(() => {
metrics.gauge('node_loop_lag_p99_ms', lag.percentile(99) / 1e6);
metrics.gauge('node_loop_lag_max_ms', lag.max / 1e6);
lag.reset();
}, 15000).unref();
Expected output: Loop lag appears beside CPU and memory on the dashboard, with an alert at 100 ms p99 that would have caught this incident on day one.
Troubleshooting
Problem
No lag lines appear but latency still spikes
Solution
The stall is outside the loop: GC pauses, kernel-level IO wait, or a downstream dependency; check GC timings and external request logs before chasing the loop.
Problem
Spikes occur at exact regular intervals
Solution
A scheduled job, cache sweep, or metrics scrape runs synchronously; find it by timestamp arithmetic against cron entries, then move it to a worker or its own process.
Problem
monitorEventLoopDelay shows huge values at startup
Solution
Module loading and JIT warmup legitimately block the loop; skip the first 30 seconds after boot before alerting, or raise the startup threshold.
Limitations
- The histogram measures loop lag only; heavy async IO queuing still needs request-level timing to diagnose.
- A 20 ms resolution cannot see sub-millisecond hiccups; lower it for finer-grained (costlier) measurement.
A blocked loop is invisible to every dashboard that measures CPU
One synchronous report render freezes every request behind it, while CPU reads a comfortable 30 percent. Event loop delay, the lag between when a timer should fire and when it actually does, is the metric that sees through this: cheap to collect, and precisely attributable when correlated with request logs.
# The log line that names the freeze
grep "loop-lag" app.log | tail -3
# {"evt":"loop-lag","p99ms":812,"at":"2026-10-22T14:03:11Z"}
# Which routes were live in that window?
jq -r 'select(.at >= "14:03:10" and .at <= "14:03:12") | .route' \
requests.json | sort | uniq -c
# 41 /api/items
# 2 /report <-- the only heavy route: culprit
# After moving /report to a worker thread
# loop-lag lines: gone. p99 latency: 1.9s -> 240ms
Correlation beats intuition
Everyone guesses a different culprit during latency spikes. The lag timestamp narrows the window to 200 milliseconds; intersecting it with request logs names the route that was running every time. Two log files and one jq join outperform an afternoon of code reading.
Keep the meter running
The instrumentation that found this incident costs a few microseconds per tick. Left in place as a dashboard gauge with a 100 ms p99 alert, it converts the entire class of blocking regressions into a page at deployment time, not a quarterly mystery.