A Node.js API can return HTTP 200 in 40 ms at the median while its 99th-percentile requests stall for 3 seconds because one event-loop turn was held hostage. Restarting that process may make the graph look healthy for five minutes, but it does not tell you whether the offender was JSON parsing, exhausted CPU, or a database that has stopped responding.

Event-loop metrics are useful because they measure whether JavaScript gets a chance to run on time. They are not a diagnosis by themselves. A high delay histogram can point to synchronous work or CPU starvation; a slow dependency can make users wait for seconds while event-loop delay remains almost normal. The incident workflow therefore starts by collecting a small set of correlated signals before changing a line of application code.

1. Treat event-loop latency as a scheduling symptom

Node.js runs JavaScript callbacks on an event loop. Network and filesystem operations can be initiated asynchronously, but their completion handlers still need time on that loop. If a callback spends 400 ms compressing data, parsing a huge payload, walking an array, or running a synchronous crypto operation, every other ready callback waits behind it.

Event-loop delay asks a practical question: how late was the runtime when it expected to run? Node exposes this with monitorEventLoopDelay() from node:perf_hooks. The resulting histogram is more useful than a single average because one 1,000 ms pause can be invisible in an average yet destroy a request percentile.

Do not equate high event-loop delay with “the database is slow.” A database query that takes 2 seconds is usually waiting outside the JavaScript thread. It raises request latency and connection-pool pressure, but it does not automatically prevent the event loop from running. Conversely, a fast database response that triggers 50 ms of synchronous result transformation can produce event-loop delay without a slow database span.

Observed symptom Most likely class of problem Next measurement
High event-loop delay and high process CPU CPU saturation or CPU-heavy JavaScript CPU by process, host, container, and worker
High delay with a sharp request or route correlation Blocked synchronous code CPU profile, route labels, payload sizes
High request latency but low event-loop delay Downstream I/O pressure Dependency latency, errors, connection pools, queues

2. Add the two Node.js metrics before an incident

Use both event-loop delay and event-loop utilization. Delay measures lateness in a sampling histogram. Utilization estimates how much time the event loop spent active rather than idle during an interval. Either metric alone can mislead: a brief blocking operation may create a dramatic delay spike, while sustained CPU work may show a persistently busy loop even when individual delay samples are less spectacular.

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

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

let previousElu = performance.eventLoopUtilization();

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

  const toMs = (nanoseconds) => nanoseconds / 1e6;

  const metrics = {
    event_loop_delay_p50_ms: toMs(loopDelay.percentile(50)),
    event_loop_delay_p95_ms: toMs(loopDelay.percentile(95)),
    event_loop_delay_p99_ms: toMs(loopDelay.percentile(99)),
    event_loop_delay_max_ms: toMs(loopDelay.max),
    event_loop_utilization: currentElu.utilization
  };

  console.log(JSON.stringify(metrics));
  loopDelay.reset();
}, 10000).unref();

The histogram values returned by monitorEventLoopDelay() are in nanoseconds, which is why the example converts them to milliseconds. Export these values to your metrics backend rather than relying on application logs; a log line every 10 seconds is hard to correlate with a traffic surge, a deployment, or a slow dependency.

The 20 ms resolution in this example is a measurement choice, not a universal setting. It is suitable for an HTTP service where pauses of tens or hundreds of milliseconds matter. A service with a strict 10 ms latency objective may need finer measurement; a low-priority batch worker may accept coarser data and fewer metrics.

3. Establish a baseline from successful traffic

Do not begin with an arbitrary alert such as “page when event-loop lag exceeds 100 ms.” First capture at least several normal traffic periods and compare delay percentiles with your service objective. For an API whose normal p99 is 120 ms, a 200 ms event-loop pause is material. For a nightly worker that runs one task per minute, the same pause may be irrelevant.

Record these dimensions together on the same dashboard or trace view:

  • HTTP request count, p50, p95, and p99 latency, grouped by route.
  • Event-loop delay p50, p95, p99, and maximum for each process.
  • Event-loop utilization for the same reporting interval.
  • Process CPU time, resident memory, garbage-collection activity if available, and restart count.
  • Host or container CPU usage, CPU throttling signals where your platform exposes them, and memory pressure.
  • Dependency duration, error rate, timeout count, and pool or queue utilization for PostgreSQL, Redis, HTTP clients, or brokers.
  • Request attributes that alter work size: body bytes, response bytes, tenant, job type, and batch length.

The overlooked part is cardinality discipline. Label a metric with route or a bounded job_type; do not label it with a user ID, URL path containing arbitrary IDs, SQL text, or request ID. Those values belong in traces and logs. High-cardinality metrics can become an observability cost and reliability problem during the exact incident they were meant to explain.

4. First branch: find blocked synchronous JavaScript

Suspect blocked synchronous work when event-loop delay rises sharply on one or a few Node processes, request latency rises at the same time, and the event is associated with a route, message type, or unusually large payload. CPU may spike, but it does not need to peg every CPU on the host: a single JavaScript thread can be blocked while the rest of the machine looks underused.

Common offenders are easy to hide in code review because they appear harmless at small input sizes:

  • JSON.parse() or JSON.stringify() on multi-megabyte objects.
  • Regular expressions with pathological input or excessive backtracking.
  • Synchronous filesystem methods such as readFileSync() on request paths.
  • Compression, image work, encryption, hashing, or archive generation on the main thread.
  • Sorting, grouping, serializing, or recursively transforming a large in-memory result set.

Capture a CPU profile during the spike rather than guessing from a source search. The built-in inspector can produce a profile in a controlled environment, while production teams commonly use their APM profiler or an on-demand profiling workflow. Your question is specific: which JavaScript function consumed the active time during the same 10-second window in which p99 delay rose?

node --inspect server.js

In Chrome DevTools or another compatible inspector client, record a CPU profile while reproducing the route with production-like payload sizes. A profile that shows time in a serializer, a regex, or application transformation code gives you a code-change candidate. A profile dominated by idle waiting or native runtime activity is a reason to investigate elsewhere before rewriting business logic.

5. Second branch: separate CPU saturation from one bad callback

High event-loop utilization does not automatically mean that a single function is blocking the loop. Your process may simply be competing for CPU. In a container limited to one CPU, a Node process handling enough concurrent callbacks can be runnable almost continuously; on an overloaded host, the operating system may delay the process even if your own code is reasonable.

Compare process CPU with the CPU available to the workload, not just total machine CPU. A process near the effective CPU limit, high event-loop utilization, and broad latency degradation across routes points toward saturation. A short delay spike concentrated around one endpoint points more strongly toward a blocking callback or workload-shaped algorithm.

Pattern Interpretation Preferred response
One route, one payload shape, sharp delay spikes Likely synchronous hot path Profile, bound input, move CPU work off the loop
Most routes degrade, CPU stays near available capacity Likely CPU saturation Reduce load, add capacity, review concurrency and work per request
One process is worse than peers Skewed traffic, hot tenant, or local runtime condition Compare request mix, payload size, CPU, memory, and traces per instance
CPU is low but request latency is high Likely waiting on downstream I/O Inspect dependency spans, pools, timeouts, and queues

Scaling out can reduce saturation, but it cannot eliminate synchronous blocking within each process. Adding four replicas may lower the chance that any one request lands behind a 500 ms callback; it still permits that callback to delay unrelated work on whichever process receives it. That distinction matters when deciding whether to provision capacity or change an execution model.

6. Third branch: prove downstream I/O pressure

When users report slowness and event-loop delay is near its baseline, do not force an event-loop explanation. Start with the request waterfall: time in DNS, outbound HTTP, database queries, cache calls, message publishing, and connection acquisition. A request waiting 1,500 ms for an upstream API is slow, but the awaited promise generally gives the event loop an opportunity to serve other callbacks.

Downstream pressure usually produces a different cluster of evidence:

  • Dependency duration and timeout rate rise before application request latency.
  • HTTP-agent sockets, database pool wait time, or broker consumer lag increase.
  • Many requests are slow at once, often across routes sharing one dependency.
  • Event-loop delay remains normal or rises only later as timeout callbacks, retries, logging, and response handling accumulate.

The “later” case is important. A failing dependency can eventually create event-loop trouble indirectly if the application retries aggressively, serializes large error objects, emits excessive logs, or lets in-flight requests grow without a bound. The primary fix is still not “optimize the event loop.” It is a dependency timeout, a concurrency limit, backpressure, a circuit breaker, a pool adjustment, or a repair of the downstream service.

Measure queue time separately from execution time. For a database pool, distinguish “waited 800 ms for a connection” from “query ran for 800 ms.” Those are different owners and different remediations.

7. Run a 15-minute incident workflow

During an active incident, avoid the common sequence of restart, add replicas, and search for synchronous APIs. That sequence destroys evidence and can leave the underlying workload intact. Use a short, repeatable decision path instead.

  1. Mark the incident start time and identify the affected request routes, consumer groups, or job types.
  2. Compare request p99 with event-loop delay p99 and maximum over the same 1-minute and 10-minute windows.
  3. Check event-loop utilization, process CPU, available CPU, and whether only one instance is abnormal.
  4. Inspect dependency latency, errors, timeout volume, and connection-pool or queue wait time.
  5. Capture a CPU profile if delay is elevated and the process is active enough to explain it.
  6. Compare the CPU profile with recent deployment changes, request-body sizes, response sizes, and traffic mix.
  7. Apply the smallest reversible mitigation: rate-limit an expensive route, reduce batch size, disable a costly optional feature, or shed nonessential work.
  8. Preserve the before-and-after metrics and traces before declaring recovery.

Suppose an image-import endpoint starts timing out. If p99 event-loop delay jumps from a normal low baseline to hundreds of milliseconds only when uploads exceed a certain size, profile the process and look for synchronous image processing or serialization. If delay remains normal but a storage API span takes 4 seconds and the HTTP client’s queued requests climb, cap upload concurrency and investigate storage. If all routes degrade while each container consumes its CPU allocation, reduce concurrent work or add capacity first, then profile to lower CPU per request.

8. Change code only after the metric pattern identifies an owner

Different diagnoses imply different fixes. This is why an event-loop metric should not be used as a generic mandate to “make everything async.” Changing a function to return a promise does not move CPU work off the JavaScript thread if the expensive calculation still happens before the promise resolves.

Confirmed cause Code or operational change Metric that should improve
Large synchronous transformation Limit input size, process in chunks, stream data, or use a worker-thread design for CPU-bound work Event-loop delay p99 and route p99
CPU saturation Reduce work per request, control concurrency, add appropriate capacity, or move batch jobs away from serving processes Event-loop utilization, CPU pressure, broad route latency
Database or HTTP dependency slowdown Set deadlines, bound retries, tune pools only with evidence, and add backpressure or degradation behavior Dependency duration, pool wait, timeout rate, request latency
Traffic skew on a single instance Fix routing affinity, partition hot tenants, or rebalance consumers Per-instance request rate, CPU, and delay distribution

Worker threads are not free performance. They add message passing, data-copy or transfer decisions, memory overhead, failure handling, and a new concurrency boundary. Use them when profiling identifies sustained CPU-bound work that cannot be reduced or streamed; do not introduce them to solve a database timeout.

9. Build the dashboard and runbook this week

A useful first implementation takes less effort than an emergency rewrite. Add the monitorEventLoopDelay() and eventLoopUtilization() instrumentation to one service, export the metrics every 10 seconds, and place them beside request percentiles and dependency telemetry. Then run a controlled load test with a normal request and one deliberately expensive payload to verify that the dashboard shows a different pattern.

Create a one-page runbook with these decision rules:

  • If event-loop delay and CPU are high, inspect CPU saturation and capture a profile before changing I/O settings.
  • If delay is high on a route or payload shape, find synchronous work and establish an input-size bound.
  • If request latency is high while delay stays normal, investigate the downstream dependency and connection queues.
  • If only one instance is affected, compare its traffic mix and resource limits with its peers before treating the issue as global.
  • If an intervention works, verify improvement in the metric that identified the problem, not only in a temporary drop in alerts after a restart.

The goal is not to keep event-loop delay at zero; real systems have garbage collection, callbacks, traffic bursts, and operating-system scheduling. The goal is to know whether a delay spike is the cause of user-visible slowness, a symptom of CPU contention, or an unrelated signal beside a dependency failure. Once that distinction is recorded in metrics before an incident, performance work becomes an engineering decision instead of a production guessing game.