open atlas
↑ Back to track
Node.js, zero to senior NODE · 04 · 01

Monitoring event-loop lag

Node runs JS on one thread, so one slow synchronous call stalls every queued timer and I/O callback. Measure event-loop lag with monitorEventLoopDelay, watch the p99 not the mean, and move CPU work off the loop.

NODE Middle ◷ 18 min
Level
FoundationsJuniorMiddleSenior
Already know this unit? Take a 1-minute quick check →

An export endpoint added a “small” feature: gzip the response with the synchronous zlib.gzipSync before sending. In load tests against that one route everything was fine — 40ms, green. In production, p99 latency on every endpoint — health checks, logins, unrelated reads — jumped from 8ms to 600ms whenever an export ran. Nobody had touched those routes. The dashboard for the export route looked healthy because the slow path was a CPU burst that ran between requests, freezing the single thread for ~250ms at a time. The team was staring at per-route latency graphs when the real signal was one number they weren’t recording: event-loop lag.

Why one slow task freezes everything

Node runs your JavaScript on a single thread. The event loop is a loop that picks up the next ready callback — an expired timer, a completed I/O, a resolved promise — runs it to completion, and only then looks for the next one. There is no preemption: while your callback is executing, nothing else in the process can run. So a single synchronous CPU burst or blocking call — zlib.gzipSync, crypto.pbkdf2Sync, a 5MB JSON.parse, fs.readFileSync on a cold disk — doesn’t just slow that one operation. It holds the thread, and every timer, every socket read, every queued HTTP callback waits behind it. That waiting time is event-loop lag: the gap between when a callback should have run and when the loop actually got to it.

The failure mode is brutal under concurrency. With one request in flight a 250ms block costs that one request 250ms. With 200 requests in flight, the block lands in the middle of the queue and adds up to 250ms of latency to a chunk of those 200 — none of which did anything wrong. This is why the hook’s per-route dashboards lied: the export route’s own latency looked fine, but it was poisoning the tail latency of everything else. The slow code and the slow symptom were on different graphs.

import http from "node:http";
import zlib from "node:zlib";

http.createServer((req, res) => {
  // ❌ blocks the single thread for the whole compression — every other
  // in-flight request's callback waits behind this one.
  const body = zlib.gzipSync(bigPayload);   // ~250ms of pure CPU
  res.setHeader("content-encoding", "gzip");
  res.end(body);
}).listen(3000);

The loop phases (and where lag accrues)

The loop runs in fixed phases, each draining its own callback queue before moving on: timers (setTimeout/setInterval callbacks whose time is up) → pending callbacks (some deferred system callbacks) → poll (where I/O completions are picked up and where the loop blocks waiting for I/O if nothing else is due) → check (setImmediate callbacks) → close (e.g. socket.on('close')). Between every callback, Node drains the microtask queues: process.nextTick first, then resolved promises.

The practical consequence: lag accrues wherever a callback overstays. A heavy setTimeout body delays the next timer; a heavy promise .then delays the next I/O the poll phase wanted to deliver. And process.nextTick is a sharp edge — because it drains before promises and before the loop advances a phase, a recursive nextTick can starve the loop entirely, blocking I/O forever while doing “tiny” work each tick.

// process.nextTick drains before the loop continues — recursion here
// starves I/O completely, even though each call looks trivial.
function spin() { process.nextTick(spin); }
spin();              // ❌ the poll phase never gets to run again

Measuring lag: monitorEventLoopDelay

The right tool is in core: perf_hooks.monitorEventLoopDelay. It samples the loop on a timer at a fixed resolution (default 10ms) and records, in a high-resolution histogram, how late each sample was. You read percentiles off it — and percentiles are the whole point, because lag is bursty: a mean of 2ms can hide a p99 of 400ms during compaction or a GC pause. Watch the tail (p99/max), not the mean. All values are in nanoseconds.

import { monitorEventLoopDelay } from "node:perf_hooks";

const h = monitorEventLoopDelay({ resolution: 20 }); // sample every 20ms
h.enable();

setInterval(() => {
  console.log({
    meanMs: (h.mean / 1e6).toFixed(2),       // healthy: sub-millisecond
    p99Ms:  (h.percentile(99) / 1e6).toFixed(2),
    maxMs:  (h.max / 1e6).toFixed(2),
  });
  h.reset();                                  // start a fresh window
}, 1000).unref();

The naive alternative is timer drift: schedule setInterval(fn, 100) and measure how much later than 100ms each tick actually fires; the overshoot is the lag. It’s cheap and dependency-free but coarse — it only samples at your interval and gives you no histogram, so it misses sub-interval spikes and can’t tell you a p99. For production, monitorEventLoopDelay is strictly better. Going further, libraries like toobusy-js poll lag continuously and let you shed load: when lag crosses a threshold, reject new requests with 503 immediately instead of accepting work you can’t serve, keeping the already-accepted requests fast.

SymptomMetric that exposes itTool
Unrelated routes spike togetherp99 / max loop lag (not mean)monitorEventLoopDelay() histogram
One operation is slow on a hot pathduration of a marked spanperformance.mark/measure + PerformanceObserver
Service overloaded, tail explodinglive lag vs a thresholdtoobusy-js → shed load with 503

Timing the suspect: performance.mark, measure, observe

Once lag is high, you still have to find which call is blocking. perf_hooks.performance gives you the high-resolution clock and a structured way to time spans without Date.now() math. Use performance.mark(name) to drop timestamps, performance.measure(name, start, end) to record the duration between two marks, and a PerformanceObserver to receive those entries as they’re emitted — so timing is decoupled from the hot path.

import { performance, PerformanceObserver } from "node:perf_hooks";

const obs = new PerformanceObserver((list) => {
  for (const e of list.getEntries()) {
    if (e.duration > 50) console.warn(`SLOW ${e.name}: ${e.duration.toFixed(1)}ms`);
  }
});
obs.observe({ entryTypes: ["measure"] });

performance.mark("gzip:start");
const body = zlib.gzipSync(bigPayload);
performance.mark("gzip:end");
performance.measure("gzip", "gzip:start", "gzip:end"); // shows up in the observer

For ad-hoc checks, performance.now() is a monotonic, sub-millisecond timer (unaffected by wall-clock changes) — const t = performance.now(); …; performance.now() - t is the right way to micro-time a suspect call.

Why this works

Why nanoseconds and a histogram instead of a single average? Because event-loop lag is the textbook case of a metric where the average is a lie. Most samples are near-zero, so the mean stays tiny even while a handful of 300ms blocks per minute are wrecking real users. A histogram keeps the distribution, so percentile(99) answers the question that matters — “how bad is it for the unlucky 1% of callbacks?” — which is exactly the population your tail latency is made of.

Pick the best fit

An endpoint must hash passwords and, separately, render a large PDF — both CPU-heavy. Loop lag is spiking and unrelated routes are slow. What is the senior fix?

Quiz

Your loop-lag mean is 1.5ms but users report random slow requests. Why is the mean misleading here, and what should you look at?

Quiz

Which line, dropped on a hot request path, will most directly inflate event-loop lag for ALL concurrent requests?

Order the steps

Order the steps to diagnose high tail latency that you suspect is event-loop lag, from first signal to durable fix:

  1. 1 Notice many unrelated routes spike p99 together, with healthy means
  2. 2 Enable monitorEventLoopDelay and confirm p99/max lag is high, not the mean
  3. 3 Add performance.mark/measure around suspect calls to find which one blocks
  4. 4 Move the blocking work off-thread (async API / worker_threads) or chunk it
  5. 5 Re-measure: confirm lag p99 returns to sub-millisecond and add load shedding as a guard
Recall before you leave
  1. 01
    Why does the mean of event-loop lag hide the problem, and what should you record instead?
  2. 02
    An endpoint does a synchronous CPU-heavy task (sync gzip / pbkdf2) and tanks throughput for all clients. What's the fix, and why doesn't it block?
Recap

Node runs your JavaScript on a single thread with no preemption, so the event loop runs each ready callback to completion before touching the next — which means one synchronous CPU burst or blocking call (zlib.gzipSync, pbkdf2Sync, a multi-MB JSON.parse, sync fs) freezes the whole process and makes every queued timer and I/O callback wait behind it. That wait is event-loop lag, and under concurrency it lands on the p99 of every in-flight request, not just the slow one, which is why per-route latency graphs can look healthy while the service is on fire. The loop drains fixed phases — timers → pending → poll (where it blocks for I/O) → check (setImmediate) → close — and microtasks (process.nextTick, then promises) between each callback, so a recursive nextTick can starve I/O entirely. Measure lag with perf_hooks.monitorEventLoopDelay({ resolution }), which records a nanosecond histogram you read via .mean, .max, and .percentile(99) and .reset() per window — and always watch the tail, because the mean of a bursty signal is a lie. Localize the offending call with performance.mark/measure and a PerformanceObserver (or performance.now() for ad-hoc timing), then fix it by moving the CPU work off the loop with an async core API or worker_threads, or by chunking it across setImmediate. Sub-millisecond p99 lag is healthy; tens-to-hundreds of ms means users feel it — so alert on p99, not the mean, and add load shedding so overload degrades into honest 503s instead of a frozen thread. Now when you see unrelated routes spiking together, your first question is: what is the p99 of event-loop lag, and which synchronous call is holding the thread?

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.