The Node.js event loop, explained by the bugs it causes
- Node.js
- Event Loop
- Performance
- libuv
- NestJS
Most Node.js performance problems I've debugged in NestJS and Fastify services weren't slow databases. They were a blocked event loop: one request doing CPU work while every other request waited behind it. Understanding the event loop is the fastest way to stop writing those bugs.
One thread runs your JavaScript
Node runs your JavaScript on a single thread. I/O — sockets, files, DNS — is handed to the operating system or to libuv's thread pool, and when it finishes, a callback is queued. The event loop picks queued callbacks and runs them, one at a time, to completion.
That last part is the whole story. While one callback runs, nothing else can.
The phases, in order
Each turn of the loop walks through phases, each with its own queue:
- timers — callbacks from
setTimeoutandsetIntervalwhose time has come. - pending callbacks — some deferred system I/O callbacks.
- poll — waits for and runs new I/O callbacks (most of your request handling lives here).
- check — callbacks from
setImmediate. - close callbacks — such as a socket's
closeevent.
Between every individual callback, Node drains two more queues: first everything scheduled with process.nextTick, then all resolved promise callbacks (microtasks).
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
console.log('sync');
// sync
// nextTick
// promise
// timeout / immediate — order between these two is not guaranteed hereInside an I/O callback, setImmediate always runs before a zero-delay setTimeout, because the check phase comes right after poll.
Bug 1: CPU work inside a request handler
Hashing a password with a synchronous function, parsing a huge JSON payload, or resizing an image in the request path blocks every other request for as long as it takes.
// Blocks the loop: every concurrent request waits for this.
const blocking = crypto.pbkdf2Sync(password, salt, 310_000, 32, 'sha256');
// Runs on the libuv thread pool: the loop stays free.
const pbkdf2 = promisify(crypto.pbkdf2);
const hash = await pbkdf2(password, salt, 310_000, 32, 'sha256');For your own CPU-heavy code, move it to worker_threads or out of the process entirely (a queue and a worker service).
Bug 2: a starved thread pool
The async fs, crypto.pbkdf2, zlib, and dns.lookup calls share libuv's thread pool, which has 4 threads by default. Fire 50 concurrent password hashes and DNS lookups start queueing behind them — and suddenly unrelated outbound HTTP calls are slow. You can raise the size with the UV_THREADPOOL_SIZE environment variable, but the real fix is usually to stop doing that much work in one process.
Bug 3: recursive nextTick and microtask loops
Because process.nextTick and promise callbacks are drained completely before the loop moves on, code that keeps scheduling more of them never lets I/O run:
function spin() {
process.nextTick(spin); // the poll phase never gets a turn
}If you need to yield inside a long loop, setImmediate gives the I/O phase a turn on every iteration.
Measure the loop, don't guess
Node ships a histogram of event-loop delay. Export it as a metric and alert on the p99:
import { monitorEventLoopDelay } from 'node:perf_hooks';
const delay = monitorEventLoopDelay({ resolution: 20 });
delay.enable();
setInterval(() => {
const p99ms = delay.percentile(99) / 1e6; // nanoseconds → ms
metrics.gauge('event_loop_delay_p99_ms', p99ms);
delay.reset();
}, 10_000).unref();When latency spikes and the database looks healthy, this number usually tells you why.
The rules I follow
- No synchronous crypto, compression, or large parsing in request handlers.
- CPU-heavy work goes to
worker_threadsor a queue-backed worker. - Watch thread-pool pressure when using async
crypto,zlib, andfstogether. - Never schedule
nextTickor microtasks recursively. - Ship event-loop delay as a metric from day one.
Node is fast at what it was built for: lots of concurrent I/O. Keep CPU work off the loop and it stays that way.