|$ curl https://forge-ai.dev/api/markdown?path=docs/nodejs/event-loop
$cat docs/event-loop-&-libuv.md
updated Recently·28 min read·published

Event Loop & libuv

Node.jsAsyncIntermediate🎯Free Tools
Introduction

The event loop is the runtime construct that lets Node.js perform non-blocking I/O despite JavaScript being single-threaded. It offloads operations to the kernel or a worker thread pool, then resumes your callbacks when those operations complete. Understanding the event loop is essential for writing correct, high-performance Node.js applications.

Node.js delegates most asynchronous work to libuv, a C library that provides the event loop and a thread pool for operations that cannot be done asynchronously by the operating system. The event loop is divided into phases, each with a specific purpose and queue of callbacks.

This guide walks through each phase, explains how timers and I/O are scheduled, compares process.nextTick and queueMicrotask to the loop phases, and shows how to detect and mitigate event loop lag in production.

Event Loop Phases

A single tick of the Node.js event loop consists of six main phases. The loop runs continuously while there is pending work. When a phase has no more callbacks, the loop moves to the next phase.

PhasePurposeTypical Callbacks
timersExecute scheduled callbackssetTimeout, setInterval
pending callbacksRun deferred I/O callbacksSystem-specific TCP errors
idle, prepareInternal libuv bookkeepingUsed by libuv only
pollRetrieve new I/O eventsfs.readFile, HTTP requests, sockets
checkExecute setImmediate callbackssetImmediate
close callbacksHandle closed connectionssocket.on('close', ...)

Between each phase, Node.js processes microtasks. process.nextTick callbacks run before microtasks from queueMicrotask or resolved promises. Because nextTick runs before the event loop continues, recursive use can starve the loop.

warning

Never perform long-running synchronous work inside a process.nextTick callback. Since nextTick runs before the event loop proceeds, it blocks I/O from being processed.
setTimeout vs setImmediate

setTimeout(callback, 0) schedules a callback in the timers phase. setImmediate schedules a callback in the check phase. Their relative order depends on context.

timeout-vs-immediate.js
JavaScript
1// Inside an I/O callback, setImmediate runs before setTimeout(0)
2const fs = require('fs');
3
4fs.readFile(__filename, () => {
5 setTimeout(() => console.log('timeout'), 0);
6 setImmediate(() => console.log('immediate'));
7});
8
9// Output in I/O context:
10// immediate
11// timeout

In the main script, the order is non-deterministic because the timers phase may fire before the poll phase reaches the immediate. In an I/O callback, setImmediate always wins because the poll phase transitions directly to check.

top-level-timers.js
JavaScript
1// Top-level context: order is not guaranteed
2setTimeout(() => console.log('timeout'), 0);
3setImmediate(() => console.log('immediate'));
4
5// May print either order depending on process startup time

info

Use setImmediate when you want to yield to I/O before running code. Use setTimeout(0) only when you explicitly need a timer boundary.
Microtasks and nextTick

Promises and queueMicrotask schedule microtasks that run after the current operation and before the next event loop phase. process.nextTick is technically not part of the event loop; it runs at the end of the current operation, before any microtasks.

nexttick-microtasks.js
JavaScript
1console.log('start');
2
3process.nextTick(() => console.log('nextTick 1'));
4Promise.resolve().then(() => console.log('promise 1'));
5process.nextTick(() => console.log('nextTick 2'));
6Promise.resolve().then(() => console.log('promise 2'));
7
8console.log('end');
9
10// Output:
11// start
12// end
13// nextTick 1
14// nextTick 2
15// promise 1
16// promise 2

Notice that all nextTick callbacks execute before any promise microtasks. This ordering is guaranteed. In practice, prefer queueMicrotask for deferring work unless you specifically need the higher priority of nextTick.

best practice

Avoid recursive process.nextTick calls. A function that schedules itself via nextTick creates an infinite loop that prevents I/O from running, effectively freezing the process.
I/O and the Thread Pool

Not all I/O in Node.js is truly asynchronous at the operating system level. Network operations use epoll, kqueue, or IOCP and do not consume thread-pool threads. File system operations, DNS lookups, and some cryptographic functions use the libuv thread pool, which has a fixed default size of 4 threads.

thread-pool.js
JavaScript
1const crypto = require('crypto');
2const os = require('os');
3
4console.log('Thread pool size:', os.availableParallelism());
5// Default is typically 4; you can override it at startup:
6// UV_THREADPOOL_SIZE=128 node app.js
7
8const start = Date.now();
9for (let i = 0; i < 5; i++) {
10 crypto.pbkdf2('secret', 'salt', 100000, 64, 'sha512', () => {
11 console.log(`pbkdf2 done in ${Date.now() - start}ms`);
12 });
13}

With a thread pool of 4, the fifth pbkdf2 call waits for a thread to free up. This serializes part of the workload and increases latency. For CPU-bound tasks like password hashing, prefer bcrypt or argon2 with configurable work factors, or offload to worker threads.

📝

note

Set UV_THREADPOOL_SIZE only at process startup; changing it later has no effect. For heavy file system or DNS workloads, consider scaling horizontally across processes rather than increasing the pool indefinitely.
Measuring Event Loop Lag

Event loop lag is the delay between scheduling a timer and the event loop actually executing it. High lag indicates that the event loop is blocked by synchronous work. In production, sustained lag above 50 ms degrades response times and can cause health checks to fail.

event-loop-lag.js
JavaScript
1function measureEventLoopLag() {
2 let last = performance.now();
3
4 setInterval(() => {
5 const now = performance.now();
6 const lag = now - last - 1000;
7 console.log(`Event loop lag: ${lag.toFixed(2)}ms`);
8 last = now;
9 }, 1000).unref();
10}
11
12measureEventLoopLag();
13
14// Simulate blocking work every 3 seconds
15setInterval(() => {
16 const blockUntil = Date.now() + 500;
17 while (Date.now() < blockUntil) {}
18}, 3000);

The performance.eventLoopUtilization() API in Node.js 20+ provides a more precise view of how busy the event loop is relative to idle time. Combine it with perf_hooks and application metrics to build dashboards.

elu.js
JavaScript
1const { performance } = require('perf_hooks');
2
3const start = performance.eventLoopUtilization();
4setInterval(() => {
5 const elapsed = performance.eventLoopUtilization(start);
6 console.log('utilization:', (elapsed.utilization * 100).toFixed(2) + '%');
7}, 5000);

best practice

Export event loop lag and utilization as Prometheus metrics. Alert on p99 lag above your SLO threshold, not on average lag, which hides tail latency spikes.
Blocking vs Non-Blocking Code

Mixing blocking and non-blocking operations in the same function is a common source of subtle bugs. The event loop only benefits your application when all I/O is non-blocking and synchronous work is kept short.

blocking-vs-nonblocking.js
JavaScript
1// WRONG: blocks the event loop
2app.get('/data', (req, res) => {
3 const data = fs.readFileSync('./large-file.json');
4 res.json(JSON.parse(data));
5});
6
7// RIGHT: uses streams and async I/O
8app.get('/data', (req, res) => {
9 const stream = fs.createReadStream('./large-file.json');
10 stream.pipe(res);
11});

Even small synchronous calls add up under load. Parsing a 1 MB JSON file takes only a few milliseconds, but with thousands of requests per second those milliseconds compound into seconds of event loop latency.

warning

JSON parsing is a frequent source of event loop blocking. For payloads larger than a few hundred kilobytes, use streaming JSON parsers like JSONStream or stream-json.
libuv Internals at a Glance

libuv is the C library at the heart of Node.js asynchronous operations. It abstracts platform-specific event notification mechanisms into a unified cross-platform API. On Linux it uses epoll, on macOS kqueue, and on Windows IOCP.

OperationMechanismBlocks Thread Pool?
TCP/UDP socketsKernel event notificationNo
File systemThread poolYes
DNS lookupThread pool (getaddrinfo)Yes
Crypto pbkdf2Thread poolYes
Child process spawnPlatform-specificPartially
🔥

pro tip

Use dns.setDefaultResultOrder('ipv4first') if your application experiences slow DNS resolution on dual-stack networks. This avoids waiting for IPv6 resolution when IPv4 is preferred.
$Blueprint — Engineering Documentation·Section ID: NODE-01·Revision: 1.0