Runtimeevent loopnodev8blockingconcurrency

Event-Loop Lag: One Callback, Everybody Waits

A single-threaded event loop runs one callback at a time. A 200ms JSON parse does not just make that request slow — it delays every other pending task by 200ms, including the health check that is about to fail.

Follow the diagnosis

Frame the diagnosis

Performance work starts from a symptom and a signal — never from a resource dashboard.

Diagnostic question
Latency across every endpoint got worse at once with low CPU per request — is something blocking the event loop?
Symptom
All endpoints degrade together, including trivial ones like `/health`. The slowdown does not correlate with any single route's traffic, and per-request CPU looks unremarkable.
Signal
Event-loop lag (the delay between when a timer should fire and when it does) confirms it directly. Per-endpoint latency misleads, because the blocking work is often in a *different* endpoint than the one showing symptoms.
SymptomSignalMeasurementHypothesisEvidenceRoot CauseChangeValidationRegression Check

Why one slow callback poisons every request

Runtime-specific · Node.js / V8; the same model applies to browser main threads and to any single-threaded event-loop runtime.

A Node process handles concurrency by interleaving, not by parallelism: the event loop takes one ready callback, runs it to completion, and only then considers the next. While a callback runs, nothing else in that process progresses — not other requests, not timers, not I/O completions that have already arrived.

This makes CPU-heavy work uniquely damaging in a way that is easy to miss. In a thread-per-request server, a request that burns 200ms of CPU makes *that* request slow and competes with others for cores. In an event loop, that same 200ms is added to the waiting time of every callback already queued behind it. Ten concurrent requests, one of which does a 200ms synchronous parse, means the other nine each wait up to 200ms extra for work that has nothing to do with parsing.

The observable consequence is a system that degrades globally from a local cause. /health — which does nothing but return 200 — becomes slow, which is the clearest possible signal that the problem is not in any handler, and is also how these incidents often escalate: the load balancer removes healthy instances because their health checks timed out behind someone else's JSON.

Four concurrent requests; one blocks the loop for 180ms
critical pathILLUSTRATIVE
065130195260
GET /report — sync JSON.parse of 40MB180 ms
GET /health — queued, would take 1ms1 ms
GET /users/42 — queued22 ms
db callback — result arrived at t=30ms14 ms
GET /report — sync JSON.parse of 40MBHolds the loop. Nothing else can run for 180ms.
GET /health — queued, would take 1msArrived at t=5ms. Waited 175ms for work that takes 1ms.
GET /users/42 — queuedArrived at t=12ms. Its own work is fast; its latency is someone else's fault.
db callback — result arrived at t=30msThe database answered promptly; the process could not pick up the answer.

Measuring lag directly

Runtime-specific · Node.js / V8

Event-loop lag has a clean definition: schedule a timer for N milliseconds, measure how late it actually fires, and the excess is how long the loop was busy with something else. It requires no knowledge of what the blocking work was, which is exactly what makes it a good detector — it catches every cause of blocking, including ones you have not thought of.

Publish it as a histogram, not a mean. Lag is bursty by nature: a process can be perfectly responsive for 59 seconds and blocked for one, and an average over that minute looks fine. p99 lag is the number that corresponds to user-visible damage, in the same way and for the same reason as Percentiles: Which One, and How Many Users Is That? generally.

Modern Node exposes this natively via perf_hooks.monitorEventLoopDelay, which samples with less overhead than a naive timer loop and gives you percentiles directly. Either way, the metric belongs on the default dashboard for every Node service — it is cheap, it is unambiguous, and it is the single fastest way to distinguish "our dependency is slow" from "we are blocking our own loop".

Two ways to measure the same thing
1// Naive but portable: how late did a timer fire?
2const INTERVAL = 100
3let last = process.hrtime.bigint()
4setInterval(() => {
5 const now = process.hrtime.bigint()
6 const elapsedMs = Number(now - last) / 1e6
7 last = now
8 // Anything above the interval is time the loop spent blocked.
9 recordHistogram('event_loop_lag_ms', elapsedMs - INTERVAL)
10}, INTERVAL).unref()
11
12// Built in, lower overhead, gives percentiles directly:
13import { monitorEventLoopDelay } from 'node:perf_hooks'
14const h = monitorEventLoopDelay({ resolution: 10 })
15h.enable()
16setInterval(() => {
17 recordGauge('event_loop_lag_p50_ms', h.percentile(50) / 1e6)
18 recordGauge('event_loop_lag_p99_ms', h.percentile(99) / 1e6)
19 h.reset()
20}, 10_000).unref()
21
22// Alert on p99, never on the mean: a process blocked 1s in every 60
23// averages out to ~17ms of lag, which looks completely healthy.

Getting the CPU work off the loop

Runtime-specific · Node.js / V8

Once lag is confirmed, a CPU profile identifies which callback is holding the loop (Self Time, Total Time, and Where the CPU Went). The fixes fall into four families, and they differ mainly in how much of the problem they actually remove versus relocate.

Moving work to a worker thread or a separate process genuinely removes it from the loop, at the cost of serialization across the boundary and a more complex deployment. Chunking — breaking a long synchronous computation into pieces that yield between them — keeps the work in-process and bounds the maximum block, but total latency for that request gets slightly worse and the code becomes harder to follow. Streaming replaces "parse 40MB then act" with incremental processing, which is usually the best answer for the specific case of large payloads. And sometimes the honest fix is upstream: an endpoint that needs to parse 40MB probably should not have been handed 40MB (Large Requests and Documented Limits).

The trap worth naming is async as a supposed fix. Marking a function async does not move CPU work off the loop; a synchronous 180ms computation inside an async function blocks exactly as long as it did before. await yields only at genuine asynchronous boundaries, so an await on an already-resolved promise buys nothing. This misunderstanding is common enough that it is worth checking explicitly before accepting "we made it async" as a resolution.

Four ways to stop blocking, and what each actually costs
ApproachWhat it does to the blockCostBest when
Worker threadsRemoves it from the loop entirelySerialization across the boundary; more complex lifecycle and failure handlingCPU-heavy work on data that transfers cheaply
Separate service / processRemoves it, and isolates its failures tooNetwork hop, deployment surface, its own scaling storyHeavy work with a natural service boundary
Chunking / yieldingBounds the maximum block, does not remove the workSlightly worse latency for that request; harder-to-read codeLarge in-process computation that cannot move
StreamingAvoids materializing the whole payload at onceIncremental logic is more complex than parse-then-actLarge request or response bodies
Marking it `async`Nothing. Synchronous CPU still blocksFalse confidence, and a closed incident that reopensNever, for CPU-bound work

Key points

  • An event loop runs one callback to completion at a time, so CPU-heavy work adds its full duration to everything already queued.
  • The signature is global degradation from a local cause — trivial endpoints like /health become slow, which no per-route theory explains.
  • Event-loop lag (timer scheduling delay) detects every cause of blocking without needing to know what the work was.
  • Report lag as p99, never as a mean: a process blocked one second per minute averages to a healthy-looking number.
  • Marking a function async does not move CPU work off the loop; only workers, separate processes, chunking or streaming do.

Follow the diagnosis

The causal chain, hop by hop — and the readings that invite the wrong conclusion.

  1. 1
    Client → handler: a report endpoint receives a 40MB JSON body and calls JSON.parse synchronously.
  2. 2
    Handler → event loop: the parse occupies the loop for ~180ms, during which no other callback can be dispatched.
  3. 3
    Event loop → queued work: every pending request, timer and I/O completion waits behind it, gaining up to 180ms of latency.
  4. 4
    Queued work → health check: /health responds in 176ms instead of 1ms, exceeding the load balancer's timeout threshold.
  5. 5
    Load balancer → fleet: the instance is marked unhealthy and removed, concentrating traffic on the remaining instances, which then block the same way.
What this evidence makes people conclude — wrongly
  • "Every endpoint is slow, so it must be the database" — check loop lag first; a blocked loop degrades everything including endpoints that touch nothing.
  • "CPU is only 40%, so we are not CPU-bound" — a single-threaded loop saturates one core; on an 8-core box that is 12.5% total CPU and complete unresponsiveness.
  • "We made the handler async, it is fixed" — async yields at asynchronous boundaries only; synchronous CPU blocks identically.
  • "Average loop lag is 4ms, that is fine" — lag is bursty, and the mean averages the blocked second away. Read p99.
  • "Health checks are failing, the instance is unhealthy" — the instance is fine; it is blocked behind someone else's parse.

Measure, fix, validate

An optimization is not finished until the metric that motivated it has moved.

How to measure it
  • • `event_loop_lag` as a histogram with p50 and p99, via `perf_hooks.monitorEventLoopDelay` or a timer-drift loop.
  • • A CPU profile taken during a lag spike, to attribute the block to a specific callback ([[cpu-profiling]]).
  • • Latency of a deliberately trivial endpoint (`/health`), which acts as a canary for loop responsiveness independent of any handler.
  • • Request payload sizes at the ingress, since large bodies are the most common source of synchronous parse blocks.
  • • Process CPU alongside lag: high lag with high CPU is genuine compute; high lag with low CPU suggests a synchronous I/O call on the loop.
What actually fixes it
  • • Move the CPU-heavy work to a worker thread or a separate service, which removes it from the loop rather than rescheduling it.
  • • Stream large payloads instead of materializing and parsing them whole, which addresses the most common cause directly.
  • • Bound accepted request body sizes at the ingress so a single caller cannot hand the loop 40MB of work ([[large-requests]]).
  • • Chunk unavoidable in-process computation so it yields periodically, bounding the maximum block even if total work is unchanged.
  • • Add an event-loop lag alert with a p99 threshold, so the next occurrence is detected before health checks start failing.
How you know it worked
  • • p99 event-loop lag returns to single-digit milliseconds under the same load that previously produced spikes.
  • • `/health` latency stays flat while the heavy endpoint is exercised — the canary is the direct test of whether isolation worked.
  • • Latency improvements appear across *unrelated* endpoints, which is the specific signature that the fix addressed a shared blocker.
  • • Confirm the work actually moved: worker-thread CPU should rise as main-thread block time falls, rather than both simply looking better under lighter test load.
What it costs
  • • Worker threads add serialization cost across the boundary; for large objects that copy can approach the cost of the work being moved.
  • • Chunking makes the individual request slightly slower and the code meaningfully harder to reason about.
  • • Separate services add a network hop, an independent failure mode and their own scaling and deployment surface.
  • • Body-size limits reject requests that some client legitimately wanted to make, which needs a documented contract rather than a silent 413.
Stop it coming back
  • An alert on p99 event-loop lag above a threshold derived from your latency budget, on every Node service by default.
  • A CI check or load test that exercises the maximum accepted payload size and asserts a lag ceiling.
  • An ingress body-size limit enforced by configuration, so a new endpoint cannot accidentally accept unbounded input.
  • A lint or review rule flagging synchronous filesystem, crypto and compression calls in request paths, which are the usual accidental blockers.

Accuracy

Performance numbers are conditional. These are the conditions.

What these numbers depend on
  • RUNTIME-SPECIFICThis is the Node.js / V8 execution model, shared with browser main threads. Runtimes with real thread-per-request models degrade differently under the same CPU-heavy handler, and the global-degradation signature does not appear.
  • ILLUSTRATIVEThe 40MB payload and 180ms parse are chosen to make the interleaving legible. Parse cost depends on payload shape, V8 version and hardware.

Misconceptions

Claim
“Making a function `async` stops it blocking the event loop.”
Reality
async changes how a function returns, not where its CPU work runs. A synchronous 180ms computation inside an async function blocks the loop for exactly 180ms. Only workers, separate processes, chunking or streaming change that.
Claim
“Low process CPU means the loop is not the problem.”
Reality
A single-threaded loop can only saturate one core. On an 8-core machine, a completely blocked process shows around 12.5% CPU — a number most dashboards read as idle.
Claim
“Slow health checks mean the instance is unhealthy.”
Reality
The instance is often perfectly healthy and merely blocked behind one expensive callback. Removing it from the pool concentrates load on the remaining instances and spreads the blocking, which is how a local problem becomes a fleet outage.

Apply it

Where the depth lives

Operating Systems
The event loop over epoll/kqueue

Node's loop is a user-space scheduler on top of I/O multiplexing — knowing what the kernel provides explains why I/O is non-blocking and CPU is not.