The core architecture of Node.js is built upon a high-performance symbiosis between Google's V8 JavaScript runtime engine and the cross-platform asynchronous I/O library libuv. The V8 engine itself is strictly single-threaded: it operates a single Call Stack and manages a contiguous memory Heap. However, enterprise Node.js servers routinely handle tens of thousands of concurrent TCP connections without spawning an operating system thread per connection.
This scalability is unlocked by the Reactor Pattern implemented within libuv:
+-------------------------------------------------------------+
| Node.js Application |
| (JavaScript Code: Business Logic, Fastify, RSC) |
+-------------------------------------------------------------+
|
v
+-------------------------------------------------------------+
| Node.js Core (C++ / JS Bindings) |
| (fs, net, http, crypto, buffer, stream, etc.) |
+-------------------------------------------------------------+
/ \
v v
+-----------------------+ +-----------------------+
| Google V8 | | libuv |
| - Call Stack | | - Event Loop (6 Ph) |
| - Memory Heap | | - Epoll/Kqueue/IOCP |
| - Microtask Queue | | - Thread Pool (4-1024|
+-----------------------+ +-----------------------+
|
v
+--------------------------------------+
| OS Kernel (Syscalls) |
| Non-blocking Network / Sockets / IPC |
+--------------------------------------+libuv abstracts the low-level I/O multiplexing interfaces provided by operating system kernels:
Network sockets, Inter-Process Communication (IPC) pipes, and TCP/UDP streams are managed entirely asynchronously and non-blockingly by the OS kernel. When incoming network packets arrive, the OS kernel notifies libuv through its demultiplexing system call (epoll_wait, kevent, or GetQueuedCompletionStatusEx) and delivers the active file descriptors.
However, operating systems do not provide universal asynchronous non-blocking support for all operations. Specifically, POSIX file system system calls (open, read, write, stat) are blocking at the file descriptor level on major operating systems. For these operations, as well as for CPU-intensive cryptography (crypto.pbkdf2, crypto.scrypt), compression (zlib), and system DNS resolution via getaddrinfo (dns.lookup), libuv maintains an internal Thread Pool.
By default, the thread pool size is 4 worker threads. Its capacity can be tuned via the UV_THREADPOOL_SIZE environment variable (up to a maximum of 1024 threads), which must be declared prior to the initialization of the Node.js runtime.
The libuv Event Loop is an infinite synchronous loop written in C that runs as long as there are active event handles or pending timers (uv_loop_alive). Each complete cycle through all phases is designated as a tick.
Within each tick, libuv sequentially executes through 6 distinct phases:
+------------------------------------+
| START |
+------------------------------------+
|
v
+------------------------------------+
| 1. Timers (setTimeout/Interval) | <--- [Microtasks drain: nextTick, Promise]
+------------------------------------+
|
v
+------------------------------------+
| 2. Pending I/O Callbacks | <--- [Microtasks drain: nextTick, Promise]
+------------------------------------+
|
v
+------------------------------------+
| 3. Idle, Prepare (internal) | <--- [Microtasks drain: nextTick, Promise]
+------------------------------------+
|
v
+------------------------------------+
| 4. Poll (epoll_wait / I/O Read) | <--- [Microtasks drain: nextTick, Promise]
+------------------------------------+
|
v
+------------------------------------+
| 5. Check (setImmediate) | <--- [Microtasks drain: nextTick, Promise]
+------------------------------------+
|
v
+------------------------------------+
| 6. Close Callbacks (socket close)| <--- [Microtasks drain: nextTick, Promise]
+------------------------------------+
|
v
+------------------------------------+
| Check loop alive? (Yes -> Loop) |
+------------------------------------+perf_hooks.monitorEventLoopDelay to trigger Kubernetes Horizontal Pod Autoscaler (HPA) policies based on thread saturation.UV_THREADPOOL_SIZE in microservices performing high-volume JWT asymmetric signature verification or local static file serving.setImmediate, allowing the Poll phase to interleave incoming HTTP requests and maintain optimal Time to First Byte (TTFB).setTimeout and setInterval). If the loop's current monotonic clock has surpassed a timer's expiration timestamp, libuv invokes its callback through V8.ECONNREFUSED error, some platforms report the notification in this phase.setImmediate tasks are queued, the thread blocks on epoll_wait (or OS equivalent) waiting for I/O events.setImmediate callbacks are pending, the Poll phase terminates immediately without entering an extended wait.setImmediate(). This allows code to run immediately after the Poll phase completes in the current tick.uv_close (e.g., socket.on('close', ...)).Prior to Node.js 11, microtask queues (process.nextTick and Promises) drained only at the transition boundaries between libuv phases. This caused discrepancies with the browser HTML5 Event Loop specification.
Since Node.js 11+, the engine strictly adheres to the standard: microtask queues drain immediately after each individual JavaScript callback completes, regardless of which libuv phase is active.
Priority ordering for microtasks:
nextTickQueue (process.nextTick): Always holds absolute priority.microtaskQueue (Promise.then(), queueMicrotask()): Drains only after nextTickQueue is entirely empty.Application calls fs.promises.readFile. A pending Promise is created, and parameters pass into Node.js C++ bindings.
Because file I/O is blocking in POSIX, libuv packages the task into a uv_fs_t struct and enqueues it to the worker thread pool.
An available worker thread executes the blocking read() syscall and copies bytes from disk into a memory buffer.
The worker thread signals the main event loop via uv_async_send, waking the main thread from epoll_wait.
During the Poll phase, the main thread collects the result and resolves the Promise. The microtask queue drains immediately with .then() code.
For real-time operational diagnostics, the IntervalHistogram from node:perf_hooks tracks event loop latency percentiles:
import { monitorEventLoopDelay, IntervalHistogram } from 'node:perf_hooks';
import { EventEmitter } from 'node:events';
export interface EventLoopMetrics {
minMs: number;
maxMs: number;
meanMs: number;
p50Ms: number;
p90Ms: number;
p99Ms: number;
exceedsThreshold: boolean;
}
export class EventLoopMonitor extends EventEmitter {
private histogram: IntervalHistogram | null = null;
private timer: NodeJS.Timeout | null = null;
private readonly resolutionMs: number;
private readonly intervalMs: number;
private readonly warningThresholdMs: number;
constructor(options?: {
resolutionMs?: number;
intervalMs?: number;
warningThresholdMs?: number;
}) {
super();
this.resolutionMs = options?.resolutionMs ?? 10;
this.intervalMs = options?.intervalMs ?? 1000;
this.warningThresholdMs = options?.warningThresholdMs ?? 50;
}
public start(): void {
if (this.histogram) {
return;
}
// Initialize histogram sampling with configured resolution
this.histogram = monitorEventLoopDelay({
resolution: this.resolutionMs,
});
this.histogram.enable();
this.timer = setInterval(() => {
this.collectMetrics();
}, this.intervalMs);
// Ensure monitoring timer does not keep process alive
this.timer.unref();
}
public stop(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
if (this.histogram) {
this.histogram.disable();
this.histogram.reset();
this.histogram = null;
}
}
private collectMetrics(): void {
if (!this.histogram) return;
// monitorEventLoopDelay values are recorded in nanoseconds (1e6 ns = 1 ms)
const nsToMs = 1_000_000;
const metrics: EventLoopMetrics = {
minMs: Number((this.histogram.min / nsToMs).toFixed(2)),
maxMs: Number((this.histogram.max / nsToMs).toFixed(2)),
meanMs: Number((this.histogram.mean / nsToMs).toFixed(2)),
p50Ms: Number((this.histogram.percentile(50) / nsToMs).toFixed(2)),
p90Ms: Number((this.histogram.percentile(90) / nsToMs).toFixed(2)),
p99Ms: Number((this.histogram.percentile(99) / nsToMs).toFixed(2)),
exceedsThreshold: false,
};
metrics.exceedsThreshold = metrics.p99Ms > this.warningThresholdMs;
this.emit('metrics', metrics);
if (metrics.exceedsThreshold) {
this.emit('lag-warning', {
message: `Critical Event Loop P99 latency: ${metrics.p99Ms} ms (threshold: ${this.warningThresholdMs} ms)`,
metrics,
timestamp: Date.now(),
});
}
// Reset histogram to ensure clean metrics for subsequent collection window
this.histogram.reset();
}
}When processing large collections in the main Node.js thread, chunk operations using setImmediate to yield execution back to libuv's Check phase:
/**
* Processes an array in cooperative chunks without starving the Event Loop.
* Yields control via setImmediate between batches, allowing libuv
* to service network I/O in the Poll phase.
*/
export async function processInCooperativeBatches<T, R>(
items: T[],
batchSize: number,
processor: (item: T) => Promise<R> | R
): Promise<R[]> {
const results: R[] = [];
let index = 0;
while (index < items.length) {
const chunk = items.slice(index, index + batchSize);
// Process current batch synchronously
for (const item of chunk) {
results.push(await processor(item));
}
index += batchSize;
// Yield execution to the Event Loop before next batch
if (index < items.length) {
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
}
}
return results;
}Developers often use process.nextTick to defer execution. However, if process.nextTick recurses or loops continuously, V8 drains the nextTickQueue indefinitely. Control never yields back to libuv's operational phases, causing total starvation of network sockets, timers, and HTTP requests. Use setImmediate instead to allow libuv to interleave I/O operations between ticks.
Invoking fs.readFileSync, fs.writeFileSync, crypto.pbkdf2Sync, or parsing massive payloads with JSON.parse inside HTTP request handlers freezes V8's single thread. During this execution window, the process cannot accept inbound TCP handshakes or process Poll events, causing catastrophic P99 latency spikes. Always employ streams (fs.createReadStream), async crypto APIs, or offload CPU work to Worker Threads.
The default dns.lookup method invokes the synchronous getaddrinfo(3) syscall, which executes on libuv's 4-thread pool (UV_THREADPOOL_SIZE = 4). When a service executes hundreds of concurrent external HTTP requests to distinct domain names, the thread pool saturates instantly. All subsequent file system (fs) and crypto operations queue behind DNS resolution. Use native non-blocking DNS resolvers from node:dns/promises (dns.resolve4, dns.resolve6), which run asynchronously via kernel sockets without touching the thread pool.
Assigning process.env.UV_THREADPOOL_SIZE = '16' inside application JavaScript code has zero effect if libuv was already initialized prior to evaluating the script. UV_THREADPOOL_SIZE must be exported before the process starts: e.g., UV_THREADPOOL_SIZE=16 node dist/main.js or via Docker environment variables.
libuv checks for active handles, timers, or pending requests. If the count reaches zero, the process terminates with code 0.
Loop updates its monotonic clock and evaluates the min-heap to execute expired setTimeout and setInterval callbacks.
Executes deferred system callbacks from prior iterations, such as specific network error notifications.
Internal libuv housekeeping routines run to synchronize state before entering blocking kernel waits.
Calculates sleep timeout and calls epoll_wait/kqueue/IOCP to process socket events or unblock immediately if setImmediate is queued.
Executes callbacks registered via setImmediate directly following the conclusion of the Poll phase.
Dispatches resource termination events (uv_close) for closed sockets and stream handles.