Ch. 6 · Node.js

Node.js Blocking Work and Request Latency

Why a 170 ms synchronous loop in one Node.js request delays every other request, how to measure loop delay, and when to chunk or use workers.

~6 min readbeginnerupdated Oct 7, 2026

Node.js runs your JavaScript on a single thread. File reads, sockets and DNS lookups happen elsewhere (in the kernel or libuv’s thread pool), and their results come back as callbacks that the event loop runs one at a time. That design makes waiting cheap: ten thousand requests stuck on a slow database cost almost nothing. It makes computing expensive in a specific way. While one callback is busy, no other callback runs, no matter which request it belongs to.

Interviewers ask about this because it’s the most common way a healthy Node service suddenly gets slow for everyone. A single endpoint that does heavy work in JavaScript turns into a latency problem for the health check, the login page and every other route.

A report endpoint that slows down the health check

Here’s a small server with two routes. /report aggregates 300,000 order rows. The JSON.parse(JSON.stringify(o)) is a stand-in for the per-row cloning, validation or formatting that real report code does. /health just answers ok.

import http from 'node:http';

const orders = Array.from({ length: 300_000 }, (_, i) => ({
  id: i, customer: `c${i % 5000}`, total: (i % 997) * 1.25,
}));

async function buildReport() {
  // marked async, but nothing below awaits
  const byCustomer = {};
  for (const o of orders) {
    const copy = JSON.parse(JSON.stringify(o)); // stand-in for real per-row work
    byCustomer[copy.customer] = (byCustomer[copy.customer] ?? 0) + copy.total;
  }
  return Object.keys(byCustomer).length;
}

http.createServer(async (req, res) => {
  if (req.url === '/report') res.end(String(await buildReport()));
  else res.end('ok');
}).listen(3100);
JavaScript

A separate client process starts a /report request, waits 20 ms so the report is under way, then times a /health request:

const base = 'http://localhost:3100';
async function time(path) {
  const t = performance.now();
  await fetch(base + path).then(r => r.text());
  return (performance.now() - t).toFixed(0);
}
await time('/health'); // warm up the connection
console.log(`/health alone: ${await time('/health')} ms`);
const slow = time('/report');
await new Promise(r => setTimeout(r, 20));
const fast = await time('/health');
console.log(`/report: ${await slow} ms, /health during it: ${fast} ms`);
JavaScript

On a laptop with Node 22:

/health alone: 2 ms
/report: 197 ms, /health during it: 176 ms
Text

The health check went from 2 ms to 176 ms, and it shares no code or data with the report. The request arrived while the report loop was running, its socket became readable, and its callback sat in the queue until the loop finished. Put this behind a load balancer with a 100 ms health check timeout and it starts marking instances as unhealthy whenever someone downloads a report.

The client has to be a separate process for this experiment. If you run the client in the same process as the server, the client’s timers are blocked by the same loop and the measurement lies to you. That trap catches people writing quick benchmarks.

async doesn’t mean “runs somewhere else”

The most common misconception here, and one interviewers check for directly, is that adding async to a function makes its work non-blocking. It doesn’t. An async function runs synchronously, on the caller’s thread, until it reaches its first await. Only then does it return control. A function with no await at all runs to completion before the caller’s next line:

async function crunch() {
  console.log('crunch: start');
  const end = Date.now() + 100;
  while (Date.now() < end) {} // 100 ms of CPU
  console.log('crunch: end');
}
setTimeout(() => console.log('timer due at 0 ms'), 0);
const t = performance.now();
crunch();
console.log(`after crunch() returned a promise: ${(performance.now() - t).toFixed(0)} ms`);
JavaScript
crunch: start
crunch: end
after crunch() returned a promise: 101 ms
timer due at 0 ms
Text

The call returned a promise, but only after 101 ms of work. The zero-millisecond timer ran last. Wrapping the loop in new Promise(...) doesn’t help either: the executor function runs synchronously too.

The same goes for the synchronous APIs in core: fs.readFileSync, crypto.pbkdf2Sync, zlib.gzipSync, and JSON.parse on a 50 MB string all hold the thread. The async versions of the fs, crypto and zlib functions do their work on libuv’s thread pool, which is why they exist. JSON.parse and JSON.stringify have no async version, so large payloads are a frequent hidden culprit.

Measuring the stall instead of guessing

Endpoint latency tells you something is slow; it doesn’t tell you the event loop is the reason. monitorEventLoopDelay from node:perf_hooks measures the loop directly: it schedules a timer at a fixed resolution and records how late it fires.

import { monitorEventLoopDelay } from 'node:perf_hooks';
const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
setTimeout(() => { const end = Date.now() + 150; while (Date.now() < end) {} }, 100);
setTimeout(() => {
  h.disable();
  console.log(`p50 ${(h.percentile(50) / 1e6).toFixed(1)} ms, p99 ${(h.percentile(99) / 1e6).toFixed(1)} ms, max ${(h.max / 1e6).toFixed(1)} ms`);
}, 500);
JavaScript
p50 21.1 ms, p99 156.1 ms, max 156.1 ms
Text

Values are in nanoseconds, hence the division. The p50 sits at about the 20 ms sampling interval, so read the numbers relative to that baseline: the 150 ms busy loop shows up as a p99 and max around 156 ms. Export p99 and max as metrics in production. A spike in loop delay that lines up with a spike in latency across every route is the fingerprint of blocking work.

To find which code is responsible, take a CPU profile. node --cpu-prof server.js writes a .cpuprofile file on exit that you can open in Chrome DevTools, and the flame graph will show one wide stack under the report handler.

Fixing it, in the order that usually pays off

Make the work smaller. This is the fix people skip. In the example, the clone per row is pure waste; deleting it cuts most of the time. Streaming rows from the database instead of loading 300,000 at once, paginating the report, or precomputing aggregates in a background job removes the problem rather than moving it.

Yield between chunks. If the work is genuinely needed and moderately sized, break it up and let the loop breathe between chunks:

async function buildReportChunked() {
  const byCustomer = {};
  for (let i = 0; i < orders.length; i++) {
    const copy = JSON.parse(JSON.stringify(orders[i]));
    byCustomer[copy.customer] = (byCustomer[copy.customer] ?? 0) + copy.total;
    if (i % 5_000 === 0) await new Promise(setImmediate); // let other callbacks run
  }
  return Object.keys(byCustomer).length;
}
JavaScript

Same client, two runs:

/report-chunked: 174 ms, /health during it: 4 ms
/report-chunked: 269 ms, /health during it: 8 ms
Text

The health check is fast again. The report itself took about the same or longer, because it now shares the thread fairly instead of hogging it. That’s the honest trade: chunking improves everyone else’s latency, not the heavy request’s. Use setImmediate rather than process.nextTick or await Promise.resolve() for the yield. Microtasks and nextTick callbacks run before the loop moves on to I/O, so yielding that way never lets the waiting socket in.

Move it to a worker thread. For work that takes hundreds of milliseconds or more per call (image resizing, PDF generation, big parsing jobs), run it in worker_threads with a fixed-size pool. Now the main thread stays free and the CPU work runs in parallel. The costs are real: data has to be copied or transferred to the worker, each worker has its own heap, and an unbounded number of workers just moves the overload into memory and context switching. Size the pool near the number of cores and queue the rest.

Scale out. Running more processes (cluster or more containers) adds capacity, but every process still stalls while it runs the heavy request. It’s a capacity fix, not a latency fix.

Interview practice

Your Node API has a p99 latency of 40 ms most of the day, but several times an hour p99 jumps to 900 ms on every route at once, including /health. CPU usage on the instance also spikes during those minutes. The database dashboard looks normal. How do you investigate, and what would you change?

Answer

Every route slowing down together, with normal database latency and a CPU spike, points to blocking work on the event loop rather than a slow dependency. A slow dependency would hurt only the routes that use it, and CPU would stay low because the process would just be waiting.

I’d confirm by graphing event loop delay (monitorEventLoopDelay p99 and max) next to request latency; if loop delay jumps to hundreds of milliseconds at the same moments, that’s the cause. Then I’d correlate the spikes with request logs to find which route was running when they started, and take a CPU profile with --cpu-prof under a reproduction to see the hot function. Typical findings are a huge JSON.stringify on an export endpoint, a synchronous crypto or compression call, or a regex with catastrophic backtracking on user input.

For the fix, I’d first try to make the work smaller (pagination, streaming, removing redundant copies). If it’s still heavy, I’d chunk it with setImmediate if it’s tens of milliseconds, or move it to a bounded worker pool if it’s hundreds. I’d also keep the loop-delay metric and alert on it, so the next blocking change is caught before customers notice.

Further reading

More in Node.js

read ✓Node.js · mid

Node.js Worker Threads and CPU Parallelism

On Node 22 a worker cut main-thread lag from 455 ms to 1 ms, yet a new worker per tiny task was 550x slower. Build a bounded pool instead.

~7 min readread →
read ✓Node.js · hard

Node.js Caching Layers and Invalidation

Pick a cache layer in Node.js, choose TTLs per data type, keep failures out of the cache and stop a cold-cache stampede with one loader.

~7 min readread →
esc