A Node.js API can return a successful Kubernetes liveness check while ordinary requests sit in line for seconds. The process is alive, its port is open, and its CPU graph may look ordinary—but one synchronous code path can keep the event loop too busy to serve users.

That distinction matters during an incident: “the pod is healthy” usually means a supervisor can still see a process, not that the process can deliver its latency objective. Event-loop metrics give you a process-level signal that can explain this particular failure mode, but only when you place them next to request latency and reproduce the problem under a controlled workload.

Start with the symptom, not the event-loop diagram

A production report often starts with a graph such as p95 HTTP latency rising from 40 ms to 2.5 s while error rate remains near zero. If the Node process has not restarted and memory is stable, teams commonly investigate network latency, database queries, or a slow downstream service first. Those are reasonable suspects, but they do not explain every case.

Node runs JavaScript callbacks on an event loop. A long-running JavaScript callback prevents the loop from moving on to other callbacks: accepting and progressing request work, running timers, and resolving promise continuations all wait. The result is queueing. A request that itself needs only 5 ms of CPU can take 2 seconds because it arrived behind an expensive JSON transformation, regex operation, template render, or a loop over a large in-memory collection.

The useful diagnostic question is therefore not “is Node up?” It is: when application latency rises, does the process also show delayed opportunities to run the event loop or a high fraction of time spent active? Measure both sides of that question before changing code.

Know the two metrics that answer different questions

Node’s node:perf_hooks module provides two complementary measurements. They are related, but treating either one as a universal “event-loop health” number creates false conclusions.

Metric What it measures Useful incident question Important limitation
Event-loop delay How late the loop is able to run relative to a sampling interval “Was JavaScript or scheduling work delaying the loop?” A low value does not prove requests are fast; they may be waiting on a database or upstream API.
Event-loop utilization (ELU) The fraction of observed time the event loop was active rather than idle “Was this process busy doing event-loop work during the slow period?” High utilization can be expected during legitimate traffic bursts; it needs latency context.
Request latency Time observed by a client or HTTP middleware “Did users experience slowness?” It identifies the symptom, not whether the loop, database, network, or a queue caused it.

Delay is often called “lag,” although “delay” is more precise here. The histogram returned by monitorEventLoopDelay() records values in nanoseconds. ELU is a ratio calculated from active and idle time. During a CPU-heavy synchronous incident, it is common to see both request latency and loop delay rise, with ELU staying high. During an external dependency slowdown, request latency can rise while loop delay remains comparatively calm and ELU may fall because Node is waiting for I/O.

Build a small service that can fail in a recognizable way

Do not begin by adding metrics to a complicated production service and guessing at causation. Create a local endpoint that deliberately blocks JavaScript execution for a known amount of time. This is not a production pattern; it is a controlled failure you can recognize in charts.

const http = require('node:http');
const { monitorEventLoopDelay, performance } = require('node:perf_hooks');

function burnCpu(milliseconds) {
  const end = performance.now() + milliseconds;
  while (performance.now() < end) {
    Math.sqrt(Math.random());
  }
}

const server = http.createServer((req, res) => {
  const url = new URL(req.url, 'http://localhost');

  if (url.pathname === '/fast') {
    res.writeHead(200, { 'content-type': 'application/json' });
    return res.end(JSON.stringify({ ok: true }));
  }

  if (url.pathname === '/cpu') {
    const work = Number(url.searchParams.get('work') || 100);
    burnCpu(work);
    res.writeHead(200, { 'content-type': 'application/json' });
    return res.end(JSON.stringify({ ok: true, work }));
  }

  if (url.pathname === '/healthz') {
    res.writeHead(200);
    return res.end('ok');
  }

  res.writeHead(404);
  res.end('not found');
});

server.listen(3000, () => console.log('http://localhost:3000'));

Run it with node server.js, then visit /fast and /cpu?work=100. One request to the CPU endpoint is not yet the interesting case. The failure appears when several requests compete for the one JavaScript thread: each 100 ms block delays the next callback that needs service.

Instrument delay and utilization in the same interval

Add the following code after the imports. It emits a one-second sample, which is short enough to correlate with a local load test and long enough to avoid logging every request. In a production service, export these values through your existing metrics path rather than writing JSON to standard output forever.

const loopDelay = monitorEventLoopDelay({ resolution: 20 });
loopDelay.enable();

let previousElu = performance.eventLoopUtilization();

setInterval(() => {
  const currentElu = performance.eventLoopUtilization(previousElu);
  previousElu = performance.eventLoopUtilization();

  const sample = {
    event_loop_utilization: Number(currentElu.utilization.toFixed(3)),
    event_loop_delay_p50_ms: Number((loopDelay.percentile(50) / 1e6).toFixed(2)),
    event_loop_delay_p99_ms: Number((loopDelay.percentile(99) / 1e6).toFixed(2)),
    event_loop_delay_max_ms: Number((loopDelay.max / 1e6).toFixed(2))
  };

  console.log(JSON.stringify(sample));
  loopDelay.reset();
}, 1000).unref();

The call to reset() is deliberate. Without it, the histogram accumulates over the process lifetime, so a bad event-loop delay spike from 20 minutes ago can remain visible in a current percentile. A rolling metrics backend can retain history; each exported sample should still describe a defined interval.

Also note the unit conversion. The histogram reports nanoseconds, so divide by 1e6 for milliseconds. Forgetting that conversion produces graphs that look catastrophically large and sends an incident in the wrong direction.

Run a baseline before creating load

Start the server with instrumentation enabled and leave it idle for about a minute. Send a few sequential requests:

curl -s -o /dev/null -w '%{time_total}\n' http://localhost:3000/fast
curl -s -o /dev/null -w '%{time_total}\n' 'http://localhost:3000/cpu?work=100'

Record three baseline observations: client response time, event-loop delay percentiles, and ELU. The exact numbers depend on your laptop, container limits, and background work, so do not copy a threshold from someone else’s dashboard. What matters is establishing what this process looks like when it is responsive.

Baseline sampling prevents a common operational mistake: alerting on a raw delay value that is normal for your environment. It also catches instrumentation errors. If an idle service reports near-continuous high utilization, inspect the service’s timers, logging, telemetry exporters, and the measurement code before blaming incoming traffic.

For a real application, take the baseline during a known quiet period and label it with the deployment revision. A new dependency, an added synchronous logger, or a runtime configuration change can shift the baseline even when endpoint traffic is unchanged.

Apply controlled concurrency with autocannon

autocannon is a practical Node-oriented HTTP load generator for this experiment. Run the fast endpoint first so you learn whether the server, local network, and test tool can sustain concurrent traffic without intentional CPU work:

npx autocannon -c 20 -d 20 http://localhost:3000/fast

Then run the CPU endpoint with the same concurrency and duration:

npx autocannon -c 20 -d 20 'http://localhost:3000/cpu?work=100'

With 20 concurrent clients targeting an endpoint that blocks JavaScript for approximately 100 ms per request, requests queue behind one another. Autocannon’s latency output should rise materially compared with /fast, while your one-second samples should show a different delay and utilization profile.

Keep the test intentionally boring. Use one machine, one endpoint, a fixed duration, and one changed variable at a time. If you increase concurrency, change payload size, add TLS termination, and point at a remote database together, the resulting graph cannot tell you which mechanism caused the slowdown.

Read the three graphs as a causal pattern

Put client-side p95 latency, p99 event-loop delay, and ELU on the same time axis. A dashboard that shows only a process “up” status answers a deployment question; it cannot distinguish a responsive process from one that is merely not dead.

Observed pattern Most likely interpretation Next check
Latency rises; delay rises; ELU rises The event loop is busy or blocked often enough to delay other callbacks. Capture a CPU profile and find synchronous JavaScript, parsing, serialization, or expensive middleware.
Latency rises; delay stays near baseline; ELU is low or moderate Requests are more likely waiting outside JavaScript execution. Inspect database duration, outbound HTTP timing, connection pools, and queue wait time.
Delay spikes; latency does not move A brief block occurred, but it may not have affected the measured endpoint or enough requests. Check job runners, scheduled tasks, garbage collection context, and endpoint-level traffic.
Latency rises; ELU rises; delay stays modest The loop is busy, but callbacks may be short enough that the delay sampler does not show large gaps. Compare throughput, CPU profiles, and request queueing before declaring an event-loop block.

The decision rule is simple: do not call the event loop the bottleneck from latency alone, and do not declare it healthy from uptime alone. Require a time-correlated change in user-visible latency plus event-loop evidence before prioritizing CPU-bound JavaScript work.

Why health checks can make this incident harder to see

A liveness probe generally answers whether a process should be restarted. It is intentionally a coarse signal. Restarting a process because its event loop was briefly busy can turn overload into a restart loop, discard in-flight work, and make latency worse.

Readiness checks are closer to a responsiveness decision, but they have a trap: a /healthz route on the same event loop cannot reliably prove that the server remains responsive under a sustained block. If the loop is fully occupied, the health request itself queues. If probes are infrequent, they can also miss a short burst that was long enough to hurt hundreds of user requests.

Use separate signals for separate actions:

  • Liveness: restart only when the process is genuinely unrecoverable.
  • Readiness: remove an instance from new traffic when it cannot meet a service-level objective or is draining.
  • Latency and event-loop metrics: page or investigate when users are slow and the process evidence supports a loop-related cause.
  • Capacity metrics: scale based on sustained demand and saturation, not a single delayed timer callback.

This separation reflects a useful implication of event-loop-only monitoring: it can describe the condition of a process, but it cannot replace application-specific evidence about which route, tenant, query, or dependency created the cost.

Find the synchronous work instead of tuning the alert

Once the controlled test reproduces the production pattern, collect a CPU profile during the slow interval. Node can generate a profile with the built-in inspector, and Chrome DevTools can inspect the resulting CPU activity. For a local experiment, start Node with:

node --inspect server.js

Open chrome://inspect in Chrome, select the Node target, record a CPU profile while running autocannon, and look for functions occupying self time. In production, use your organization’s approved profiler and avoid collecting sensitive request values. The goal is to identify a named function and call path, not merely confirm that utilization was high.

Common fixes have distinct tradeoffs. Replacing synchronous file system APIs with asynchronous APIs gives the loop a chance to progress, but it does not make CPU-heavy parsing free. Moving expensive CPU work to worker_threads preserves main-loop responsiveness, but introduces worker-pool sizing, message serialization, backpressure, and operational monitoring. Splitting work into chunks using setImmediate() can improve fairness, but it may lower single-job throughput and complicate cancellation.

If the profile points to JSON serialization of unusually large responses, reducing response shape or paginating data can be cheaper than adding workers. The best fix removes unnecessary work before distributing unavoidable work.

Choose alerts that lead to an action

Alerting on event-loop delay alone is noisy because a short delay spike does not necessarily violate user latency. Alerting only on p95 latency is incomplete because it tells responders nothing about the likely class of cause. Combine signals around a response decision.

For example, create a warning condition when a service’s p95 request latency exceeds its own service objective for several consecutive measurement windows and p99 event-loop delay is meaningfully above that service’s established baseline. Route the alert to the team that owns the Node service, with links to endpoint latency, CPU, ELU, deployment markers, and dependency timing.

Use maximum delay primarily as an investigation clue, not your main alert threshold. A single maximum can be distorted by a one-off pause. Percentiles across fixed windows are generally more useful for deciding whether a recurring fraction of requests encountered a degraded event loop.

The tradeoff nobody mentions is metric-cardinality cost. Do not label event-loop delay by request ID, user ID, or full URL. Event-loop delay and ELU are process-level metrics; label them with stable dimensions such as service, environment, instance, and deployment revision. Keep route-level latency separate, with normalized route templates such as /users/:id.

Run this debugging drill this week

  1. Add a one-second monitorEventLoopDelay() export and interval-based ELU measurement to one noncritical Node service.
  2. Put p95 request latency, p99 loop delay, ELU, CPU, and deployment markers in one dashboard view.
  3. Capture a quiet-period baseline and write it in the service runbook rather than adopting a generic threshold.
  4. Reproduce a CPU-bound request path in staging using autocannon with fixed concurrency and duration.
  5. Verify that latency, delay, and utilization form the expected pattern, then capture a CPU profile.
  6. Document the decision: event-loop saturation, external waiting, or inconclusive evidence—and state the next measurement needed.

After this drill, an “up” process will no longer be mistaken for a responsive service. More importantly, the next latency incident starts with a testable correlation: if user latency rises without loop delay, look outward; if latency, delay, and utilization rise together, profile the JavaScript work that every waiting request is stuck behind.