Back to Blog

The Node.js Event Loop, and Why One Endpoint Froze Everything

The Node.js Event Loop, and Why One Endpoint Froze Everything cover image

An API endpoint was timing out under load. Not the database — the database was idle. CPU on the Node process was pinned at 100% on one core while the other seven did nothing. Requests that normally took 40ms were taking eleven seconds, and only when a particular endpoint was being hit.

That endpoint parsed an uploaded CSV synchronously. Roughly 300ms of pure computation per request. Which does not sound like much, until you understand that during those 300ms every other request in the entire process was frozen.

That is the event loop, and it is the one thing you have to understand to write Node that survives production.

One Thread, and Why That Works

Your JavaScript runs on a single thread. There is exactly one call stack. Two lines of your code never execute at the same moment.

The obvious objection is that Node handles thousands of concurrent connections, so how? Because almost nothing Node does is JavaScript. Reading a file, querying a database, making an HTTP request — those are handed to the operating system or to libuv's thread pool, and your thread moves on immediately. When the result is ready, a callback is queued to run on that single thread.

So Node is not fast because it is parallel. It is fast because it never waits. While the database is thinking, your thread is serving other requests instead of sitting idle.

Which gives you the rule that explains everything else: waiting is free, thinking is not. I/O concurrency scales beautifully. CPU work blocks everyone.

The Order Things Actually Run

This is where the classic interview question lives, and the answer is worth knowing for real reasons.

console.log("1");

setTimeout(() => console.log("2"), 0);

Promise.resolve().then(() => console.log("3"));

queueMicrotask(() => console.log("4"));

process.nextTick(() => console.log("5"));

console.log("6");

// 1, 6, 5, 3, 4, 2

The reasoning, rather than the memorised sequence:

Synchronous code runs to completion first. `1` and `6`. Nothing queued can interrupt code that is already running — this is why a blocking loop freezes everything.

Then microtasks drain, completely. `process.nextTick` has its own queue that runs before promises, then promise callbacks and queueMicrotask. So `5`, then `3`, then `4`.

Then the event loop advances to the next phase — timers, in this case, giving `2`.

The detail that matters in production: the microtask queue is drained fully before the loop moves on. A promise chain that keeps scheduling more promises can starve timers and I/O indefinitely. It is a rare bug, and a genuinely confusing one when it happens.

The loop itself has phases — timers, pending callbacks, poll (where I/O callbacks run and where it spends most of its time), check (setImmediate), close. You rarely need this detail, except for one practical consequence: inside an I/O callback, setImmediate always fires before setTimeout(fn, 0), because check comes right after poll.

What Actually Blocks

Being concrete, because "don't block the event loop" is advice nobody can act on.

Large synchronous JSON. JSON.parse on a 20MB payload is tens of milliseconds of frozen process. So is JSON.stringify on a big response.

Synchronous crypto. bcrypt.hashSync, or a high-cost-factor hash on the main thread. Password hashing is deliberately expensive — that is the point of it — so it must go to the thread pool. Use the async variants.

Loops over large arrays. Sorting, mapping or filtering a hundred thousand objects. Individually fast operations, collectively a stall.

Regex backtracking. A pathological pattern against a hostile input can hang the process entirely — a denial of service with no traffic spike.

The *Sync file APIs. readFileSync is fine at startup and wrong in a request handler.

A useful mental threshold: anything over about 10ms of continuous computation is worth moving. At 100ms you are adding that latency to every concurrent request.

Measuring It Rather Than Guessing

Event loop lag is the single most useful Node metric and hardly anyone exports it:

import { monitorEventLoopDelay } from "node:perf_hooks";

const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();

setInterval(() => {
  const p99 = h.percentile(99) / 1e6;      // ns → ms
  if (p99 > 100) console.warn(`event loop p99 lag ${p99.toFixed(0)}ms`);
  h.reset();
}, 10_000);

Healthy is single-digit milliseconds. Consistently above 100ms means requests are queuing behind computation, and that number moves before your latency graphs do — it is a leading indicator, not a lagging one.

Getting Work Off the Main Thread

Four options, roughly in order of how often I use them.

A queue and a separate worker process. The CSV parse belongs here: accept the upload, enqueue, return 202, process elsewhere. This is usually the right answer for anything the user does not need to wait for, and it is the fix we shipped.

Worker threads for CPU work the request genuinely must wait for. Real parallelism, and worth pooling since spawning one per request costs more than it saves.

Streams instead of loading everything into memory. Parsing a large CSV row by row yields to the loop naturally between chunks, and the memory profile is flat rather than a spike.

Yielding inside long loopsawait new Promise(r => setImmediate(r)) every few thousand iterations. Crude, and it works when you cannot restructure.

And run one process per core with the cluster module or your orchestrator. A single Node process uses one core no matter what; on an eight-core machine you want eight processes. This was also true of that timing-out service, which was running one.

The Rule I Give New Node Developers

Every time you write a function, ask: is this waiting, or is this thinking?

Waiting — database, HTTP, file, queue — is what Node is built for. Write it with async/await and stop worrying.

Thinking — parsing, hashing, transforming, sorting, compressing — is borrowed from every other request in the process. Ten milliseconds is fine. Three hundred is an outage waiting for enough traffic.

Our CSV endpoint got exactly enough traffic on a Tuesday to find that out.

Related Posts