CPU is “fine,” RPS is “fine,” and yet checkout p99 jumps from 120ms to 2.4s every few minutes. Classic Node failure mode: something blocked the event loop — a fat JSON.parse on the main thread, a sync crypto call, a rogue fs.readFileSync in middleware, or garbage collection pauses you never charted. Clusters and worker threads help capacity; they do not help if each process still stalls. This guide is how seniors catch loop lag before users file tickets.
⚡ TL;DR: Continuously measure event loop delay (Node
perf_hooks.monitorEventLoopDelayorevent-loop-lag). Alert on p99 delay, not only CPU%. Move CPU-heavy work off the main thread (worker_threads, child processes, or external services). Ban sync I/O on request paths. Illustrative SLO: event loop delay p99 < 50–100ms for user-facing APIs; investigate hard above 200ms. Pair with request histograms so lag and latency tell the same story.
What “lag” actually means
The event loop should turn frequently. Delay is how late a timer scheduled for “now” runs. If delay spikes to 800ms, every concurrent request in that process waits — connection pools sit idle while the CPU hashes passwords synchronously.
// lag-monitor.ts — first-class telemetry, not a side project
import { monitorEventLoopDelay } from "node:perf_hooks";
import { setInterval as wallInterval } from "node:timers";
const h = monitorEventLoopDelay({ resolution: 20 }); // ms resolution
h.enable();
export type LagSnapshot = {
meanMs: number;
p50Ms: number;
p99Ms: number;
maxMs: number;
};
export function snapshotLag(): LagSnapshot {
// values are in nanoseconds
const toMs = (ns: number) => ns / 1e6;
return {
meanMs: toMs(h.mean),
p50Ms: toMs(h.percentile(50)),
p99Ms: toMs(h.percentile(99)),
maxMs: toMs(h.max),
};
}
export function startLagReporter(emit: (s: LagSnapshot) => void, everyMs = 10_000) {
wallInterval(() => {
const s = snapshotLag();
emit(s);
h.reset();
}, everyMs).unref();
}
// Wire emit → OpenTelemetry / StatsD / CloudWatch EMF
startLagReporter((s) => {
if (s.p99Ms > 100) {
console.warn(JSON.stringify({ msg: "event_loop_lag", ...s }));
}
});
✅ Export lag p50/p99 next to HTTP latency p99.
❌ Only watch process CPU% — a single core at 40% can still stall the loop for hundreds of ms on bursty sync work.
Find the stalls: request-correlated profiling
When lag alerts fire, you need what blocked, not vibes.
// middleware that flags slow turns (Express-style)
import type { Request, Response, NextFunction } from "express";
import { performance } from "node:perf_hooks";
export function lagGuard(thresholdMs = 100) {
return (req: Request, res: Response, next: NextFunction) => {
const start = performance.now();
const before = process.hrtime.bigint();
res.on("finish", () => {
const wall = performance.now() - start;
// crude: if wall << handler CPU, elsewhere; if handler does sync work, wall ≈ block
if (wall > thresholdMs) {
console.warn(
JSON.stringify({
msg: "slow_request",
path: req.path,
wall_ms: Math.round(wall),
method: req.method,
})
);
}
});
next();
};
}
Use clinic.js / 0x / Chrome DevTools CPU profiles in staging with production-like payloads. Look for:
JSON.parse/JSON.stringifyon multi-MB bodiesbcrypt/pbkdf2sync variantszlib.*Sync,fs.*Sync- Heavy pure-JS image/pdf transforms
- Accidental
awaitmissing on a Promise that then continues with CPU work inline
// ❌ stalls the loop
import { readFileSync } from "node:fs";
import { pbkdf2Sync } from "node:crypto";
const conf = JSON.parse(readFileSync("./huge-config.json", "utf8"));
const hash = pbkdf2Sync(password, salt, 100_000, 64, "sha512");
// ✅ async I/O + async crypto; or offload to worker
import { readFile } from "node:fs/promises";
import { pbkdf2 } from "node:crypto";
import { promisify } from "node:util";
const pbkdf2Async = promisify(pbkdf2);
const conf = JSON.parse(await readFile("./huge-config.json", "utf8"));
const hash = await pbkdf2Async(password, salt, 100_000, 64, "sha512");
For CPU-bound bursts, prefer worker_threads as in Node.js Clustering & Worker Threads: CPU Scaling Without Melting the Event Loop.
// hash-worker.ts
import { parentPort, workerData } from "node:worker_threads";
import { pbkdf2Sync } from "node:crypto";
const { password, salt } = workerData as { password: string; salt: Buffer };
const hash = pbkdf2Sync(password, salt, 100_000, 64, "sha512");
parentPort?.postMessage(hash);
// main — bounded pool, don’t spawn unbounded workers per request
import { Worker } from "node:worker_threads";
import path from "node:path";
export function hashPassword(password: string, salt: Buffer): Promise<Buffer> {
return new Promise((resolve, reject) => {
const w = new Worker(path.join(__dirname, "hash-worker.js"), {
workerData: { password, salt },
});
w.on("message", resolve);
w.on("error", reject);
w.on("exit", (code) => {
if (code !== 0) reject(new Error(`worker_exit_${code}`));
});
});
}
Production tip: use a pool (Piscina, or your own) with queue limits and timeouts so worker storms don’t become a different outage.
GC, heap, and false friends
Lag is not always your code. GC pauses show up as delay spikes. Chart nodejs_heap_size_* and GC pause metrics (via OpenTelemetry / perf_hooks.PerformanceObserver for gc).
import { PerformanceObserver } from "node:perf_hooks";
const obs = new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
if (e.duration > 50) {
console.warn(JSON.stringify({ msg: "gc_pause", kind: (e as any).detail?.kind, ms: e.duration }));
}
}
});
obs.observe({ entryTypes: ["gc"], buffered: true });
If GC dominates: reduce allocation churn (object reuse carefully, streams over giant buffers, avoid building 50MB strings). --max-old-space-size only delays OOMs; it does not fix lag.
Also watch libuv threadpool saturation (UV_THREADPOOL_SIZE) for DNS/fs/crypto — waits there look like “slow awaits,” not always loop delay. Measure both.
Load testing that exposes lag
Synthetic RPS tests with tiny JSON never trip parse stalls. Use production-shaped payloads.
# illustrative k6 / autocannon mindset
autocannon -c 50 -d 30 -p 10 \
-H "content-type: application/json" \
-i ./fixtures/large-checkout.json \
http://127.0.0.1:3000/checkout
# While running: watch lag p99 endpoint /metrics
Expose a /health/lag for probes (careful: don’t let public clients DDoS it):
app.get("/health/lag", (_req, res) => {
const s = snapshotLag();
const ok = s.p99Ms < 100;
res.status(ok ? 200 : 503).json(s);
});
Wire readiness so orchestrators stop sending traffic to a wedged instance — same spirit as failing closed in API design from Express.js Best Practices.
Production checklist for Node services (and Lambda)
On long-lived Node servers (ECS/EKS/EC2): lag monitors + worker pools + cluster only after single-process is clean.
On Lambda: each invoke is short, but sync CPU still burns Duration and can trip timeouts — see Lambda Timeouts, Retries, and DLQs and Cold Starts on Node 20. monitorEventLoopDelay is less critical than avoiding sync work in the handler path; still useful in integration tests.
Error paths that log huge objects synchronously can themselves stall the loop — keep Node.js Error Handling structured and bounded.
Closing checklist
✅ Dos
– ✅ Export event loop delay histograms (p50/p99/max)
– ✅ Async I/O and async crypto on request paths
– ✅ Worker pools for CPU-bound bursts; bound concurrency
– ✅ Profile with production-sized payloads
– ✅ Correlate lag spikes with GC and slow-request logs
❌ Don’ts
– ❌ Don’t use *Sync APIs in middleware/handlers
– ❌ Don’t assume clustering fixes main-thread stalls
– ❌ Don’t alert only on CPU saturation
– ❌ Don’t parse unbounded JSON without size limits
– ❌ Don’t spawn unbounded workers per request
Related reading
- Node.js Clustering & Worker Threads: CPU Scaling Without Melting the Event Loop
- Node.js Error Handling: Production Patterns and Monitoring
- Express.js Best Practices: Build Production-Ready APIs With Node.js
- AWS Lambda Best Practices: Write Functions That Scale and Never Time Out
- Lambda Powertools for Node: Structured Logs That Survive On-Call
- Lambda Timeouts, Retries, and DLQs: Idempotent Failure Handling
Last updated on September 10, 2026
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.

Pingback: AsyncLocalStorage Overhead: Measure Before Wrapping Every Request Path - CheatCoders