The Event Loop Is a Budget
Node is fast until one synchronous function spends 200ms — then every concurrent request waits behind it. Concurrency is not parallelism, and the loop is a budget you spend.
Node's pitch is thousands of concurrent connections on a single thread, and it delivers — right up until one function spends two hundred milliseconds doing arithmetic. Then every other request in flight waits, because there is exactly one thread and that function is standing on it.
Concurrency is not parallelism. Node is excellent at waiting for many things at once. It cannot do two things at once, and the event loop is best understood as a budget that every request spends from.
One thread, shared by everyone
Your JavaScript runs on one thread. I/O does not — file reads, network calls and DNS lookups are handed to the platform or to a small thread pool, and your callback is queued for when they finish. That division is the whole design: while a request waits on Postgres, the thread is free to start twenty others.
The consequence is a specific failure mode. Every synchronous millisecond you spend is a millisecond added to the latency of every request currently in flight. A 200ms synchronous operation under 50 concurrent requests is not a 200ms problem; it is a queue, and the requests at the back of it see a second or more.
This is why Node performance problems look strange in monitoring. The endpoint doing the expensive work may look acceptable. The endpoints getting slow are unrelated ones that happen to share the process, and nothing in their code changed.
What actually blocks
The usual suspects, roughly in order of how often I have seen them:
JSON.parseandJSON.stringifyon large payloads. Both are synchronous and both are O(size). A 10MB response body serialised on every request is a blocking operation hiding inside something that looks like plumbing.- Array work over large collections. A chain of
map,filterandsortover a hundred thousand rows fetched “just this once” for a report. - Synchronous filesystem calls.
readFileSyncis fine at startup and a bug inside a request handler. It has a way of being introduced by a helper that was only ever meant for config loading. - Crypto done wrong. Password hashing is designed to be slow.
bcrypt/argon2in their async form use the thread pool; the sync variants block. Getting this wrong turns your login endpoint into a global rate limiter. - Catastrophic regex backtracking. A pattern with nested quantifiers over a crafted input can run effectively forever, on your only thread. This one is also a denial-of-service vector, not just a performance issue.
- Template and image processing in-process. PDF generation, image resizing, spreadsheet building — all CPU, all on the thread that is supposed to be answering requests.
Finding the blocker
You do not need to guess. The most direct signal is event loop lag: the difference between when a timer should have fired and when it did. When the loop is healthy that is sub-millisecond. When something is blocking, it spikes.
import { monitorEventLoopDelay } from "node:perf_hooks";
const h = monitorEventLoopDelay({ resolution: 10 });
h.enable();
setInterval(() => {
// p99 in milliseconds — alert on this, not on average CPU
console.log("loop delay p99", h.percentile(99) / 1e6);
h.reset();
}, 10_000);Export that as a metric and alert on it. It is the single most useful number for a Node service, and it usually is not being collected. Sitting at 200ms means every request is paying 200ms of queueing that no individual endpoint's timing will explain.
Once you know it is happening, a CPU profile tells you where. Run the built-in profiler under realistic load and read the flame graph for wide synchronous frames — width is time on the thread, which is exactly what you are hunting.
Where CPU-bound work belongs
Three options, in increasing order of both cost and correctness.
Yield the loop. If the work is a loop over a large collection and must stay in-process, break it into chunks and let the loop breathe between them. Crude, but it converts one 500ms stall into ten 50ms ones, which is often the difference between a timeout and a slow response.
Worker threads. For genuine CPU work that belongs in this service — parsing, hashing, image transforms — a worker pool moves it off the main thread while keeping it in the same process. Pool the workers; spawning one per task costs more than the work.
Move it out entirely. The right answer for anything heavy, bursty or slow. Push a job to a queue, let a separate worker process handle it, and return immediately with something the client can poll or subscribe to. Report generation, video processing and bulk exports all belong here, and the boundary also gives you retries and isolation for free.
The decision rule is not subtle: if the work takes longer than a handful of milliseconds and does not need to be in the response, it should not be in the request.
Streams and backpressure
The other way to spend the budget badly is memory. This is a common way to export data:
const rows = await db.query("SELECT * FROM events WHERE ..."); // 2M rows
res.json(rows.map(toCsvRow));Every row is in memory at once, then a second copy is built by map, then a third by serialisation. It works on the development dataset and takes the process down in production.
Streaming keeps a bounded amount in memory and starts sending bytes immediately:
import { pipeline } from "node:stream/promises";
await pipeline(
db.queryStream("SELECT * FROM events WHERE ..."),
toCsvTransform(),
res
);The important word is backpressure. When the client reads slowly, a properly piped stream tells the source to slow down. Push data manually without honouring the return value of write and you buffer the difference in memory — which is the same crash you were avoiding, arrived at differently. Use pipeline rather than chaining pipe by hand: it propagates errors and cleans up, which manual piping notoriously does not.
Leaks in a process that never restarts
A server process runs for weeks, so a leak that would be invisible in a script becomes an outage on a schedule. The recurring causes are few:
- A module-scope cache with no bound. A plain
Mapused as a cache, never evicted, keyed by something user-controlled. It grows forever by design. - Listeners that are added but never removed, especially on a long-lived emitter created once at startup. The warning about exceeding the max listener count is a leak notification, not a style note.
- Closures holding large objects. A callback that captures the full request body keeps that body alive as long as the callback is reachable.
- Timers that are never cleared on objects that were otherwise ready to be collected.
The diagnosis is always the same and it is worth doing properly rather than by inspection: take a heap snapshot, let the process run under load, take another, and compare. The object type that grew is the answer, and it is usually not what anyone predicted.
The short version
One thread runs your code, so every synchronous millisecond is charged to every request in flight. Measure event loop delay and alert on it — it explains slowdowns that no endpoint's own timing will. Move real CPU work to a worker or out to a queue. Stream large payloads and respect backpressure instead of buffering. And remember the process never restarts, so anything unbounded is a scheduled outage.