Finding event-loop stalls in Node before your users do
A Node service that stops responding without throwing is the least pleasant kind of failure. There is no stack trace, no error log, nothing in the crash reporter. Requests simply queue and time out, and every dashboard shows a healthy process using no CPU.
Two very different faults produce that symptom, and the first job is to tell them apart.
The two faults
The loop is blocked. Some synchronous work — a large JSON.parse, a regex
backtracking, a crypto.pbkdf2Sync, a 50MB readFileSync — is occupying the
thread. CPU is at 100% of one core. Nothing else runs until it finishes.
The loop is idle but work never resumes. An await is waiting on a promise
nothing will ever settle: a request with no timeout, a lock never released, a
callback-to-promise wrapper whose error path forgets to reject. CPU is near
zero. The process is perfectly healthy and doing nothing, forever.
They look the same from outside and have opposite fixes, so measure first.
Measuring loop delay
Node ships the instrument for this in perf_hooks. It samples how late timers
fire, which is precisely how blocked the loop is:
import { monitorEventLoopDelay } from "node:perf_hooks";
const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();
setInterval(() => {
console.log({
p50: Math.round(histogram.mean / 1e6),
p99: Math.round(histogram.percentile(99) / 1e6),
max: Math.round(histogram.max / 1e6),
});
histogram.reset();
}, 10_000);
Values are nanoseconds, so divide by 1e6 for milliseconds. On a healthy service the mean sits in single-digit milliseconds. A p99 in the hundreds means something synchronous is running long enough for users to feel it. A max in the thousands means somebody is doing real work on the main thread.
Export those three numbers to whatever you already use for metrics. This is the single most useful Node metric that most services do not collect.
When the loop is blocked
Once you know it is blocking, find it by sampling. Start the process with
--cpu-prof and reproduce, or attach with --inspect and take a CPU profile in
Chrome DevTools. The blocking function is, by definition, the one at the top of
the stack for most samples — this is the easy case.
The fixes are boring and effective:
- Replace
*Synccalls on request paths with their async forms. - Move genuine CPU work — hashing, image processing, big parses — to a
worker_thread, so it runs off the main loop entirely. - Chunk large loops with
await setImmediate()between batches so other work interleaves. - Check regexes for catastrophic backtracking. A regex that is instant on short input and hangs on a 10KB string is a backtracking problem, not a slow computer.
When the loop is idle
This is the harder one, because there is nothing to sample. The stack is empty; the work is suspended.
A stall detector is a reasonable first line: wrap operations that must finish in a timeout that reports rather than silently hanging.
export async function withDeadline(promise, ms, label) {
let timer;
const deadline = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`${label} exceeded ${ms}ms`)), ms);
});
try {
return await Promise.race([promise, deadline]);
} finally {
clearTimeout(timer);
}
}
That converts a silent hang into a loud error with a label you chose, which is usually enough to identify the culprit in one production incident instead of three.
For finding what is actually pending, async_hooks can track resources that
were created and never destroyed:
import { createHook } from "node:async_hooks";
const pending = new Map();
createHook({
init(id, type, triggerId) {
if (type === "PROMISE") return; // too noisy to track
pending.set(id, { type, at: Date.now(), stack: new Error().stack });
},
destroy(id) {
pending.delete(id);
},
}).enable();
// On SIGUSR2, print anything alive for more than a minute.
process.on("SIGUSR2", () => {
const old = [...pending.values()].filter((r) => Date.now() - r.at > 60_000);
console.log(`${old.length} long-lived async resources`, old.slice(0, 20));
});
Be honest about the cost: async_hooks slows the process measurably, and
capturing a stack per resource is expensive. This belongs in a staging
environment reproducing the hang, or behind a flag you turn on deliberately —
not on by default in production.
The three causes worth checking first
In practice, idle hangs are nearly always one of these:
- An HTTP call without a timeout. Node's default agent has no request
timeout. If the peer accepts the connection and never answers, you wait
forever. Set
AbortSignal.timeout(ms)on every outbound call — every one, not just the flaky ones. - A lock or semaphore whose release is not in a
finally. An exception on the happy path leaves the lock held, and every subsequent caller waits. - A promise created in a callback API wrapper that only resolves on success.
If the error branch never calls
reject, the awaiting code waits forever.promisifygets this right; hand-rolled wrappers frequently do not.
Wiring it into health checks
The lasting fix is that a stalled process should fail its health check rather than sit there looking fine. Report unhealthy when loop delay crosses a threshold you have chosen deliberately:
app.get("/healthz", (req, res) => {
const p99 = histogram.percentile(99) / 1e6;
if (p99 > 1000) return res.status(503).json({ status: "degraded", p99 });
res.json({ status: "ok", p99 });
});
An orchestrator will then restart it, which is not a fix but is a great deal better than a process that is up, healthy, and serving nobody.
Need an engineer who can build this?
I'm Yaseen Khatib — a Senior Full-Stack AI Engineer (MERN + TypeScript) who ships production AI systems solo. Open to senior and lead roles, remote or on-site.