Ask almost any software engineer how Node.js works, and you will hear the exact same sentence repeated like a universal law of physics:
"Node.js is single-threaded."
It is written in conference slides, repeated in job interviews, and taught in beginner bootcamps across the globe.
Yet if you start a basic Node.js HTTP server on a Linux machine and inspect the process with ps -eLf or htop, you will see something that immediately shatters that mental model:
Your single Node.js process is running eight or more native operating system threads simultaneously.
If Node.js were truly single-threaded:
- Encrypting passwords with
crypto.pbkdf2would freeze your entire backend until the hash finished computing. - Reading a 4-gigabyte file from disk with
fs.readFilewould block every incoming HTTP request on your server. - Compressing response payloads with
zlibwould cause global latency spikes for all concurrent users.
None of those operations freeze your server.
The truth is that Node.js has never been a simple single-threaded program.
Node.js is a multi-threaded C and C++ runtime engine that presents a single-threaded JavaScript execution interface to developer code.
Underneath your JavaScript functions sits an intricate coordination machine powered by Google's V8 engine, the libuv cross-platform abstraction library, and operating system kernel primitives that have evolved over three decades.
To build fast, resilient, and non-blocking production backends, you have to look past the "single-threaded" slogan and understand how Node.js actually runs your code.
Here is what is really happening beneath your call stack.
The Three Tiers of Node.js: V8, libuv, and the Kernel
To see where the threads actually live, we need to decompose a Node.js process into its three distinct architectural layers.
[ Your JavaScript Code ]
│
▼
[ Tier 1: Google V8 Engine ] ── Single-Threaded Call Stack & Heap
│
▼
[ Tier 2: libuv Runtime ] ── Event Loop + 4x Worker Threadpool
│
▼
[ Tier 3: OS Kernel ] ── Non-Blocking Sockets (epoll / kqueue / IOCP)
Tier 1: The JavaScript Execution Context (V8 Engine)
This is the only part of Node.js that is truly single-threaded.
Google's V8 engine executes your application code, user-defined functions, object allocations, and variable assignments within a single execution thread called the Main Thread.
Because the main thread executes one stack frame at a time, synchronous JavaScript loops (while (true) {}) or expensive recursive calculations will monopolize the CPU and starve the rest of your application.
Tier 2: The libuv Asynchronous Engine
When your JavaScript code calls an asynchronous Node.js API (such as reading a file, computing a hash, or resolving a domain name), V8 does not execute the underlying operating system call.
Instead, V8 passes the request across internal C++ bindings into libuv.
libuv is a high-performance C library originally built specifically for Node.js (and now also used by Julia and other languages).
libuv maintains two primary tools:
- The Event Loop: A single-threaded state machine that orchestrates callbacks and timers.
- The Worker Threadpool: A pool of pre-spawned background OS threads (defaulting to 4 threads) used to run blocking operations completely off the main thread.
Tier 3: The Operating System Kernel Subsystem
Here lies the biggest revelation in Node.js systems architecture:
Network I/O does not use the libuv worker threadpool at all.
When you create an HTTP server, connect to a PostgreSQL database, or open a WebSocket, your operating system kernel handles the network sockets through native event demultiplexers:
-
Linux:
epoll -
macOS and BSD:
kqueue -
Windows:
IOCP(I/O Completion Ports)
These kernel subsystems monitor tens of thousands of network sockets concurrently without allocating a single OS thread.
File System I/O vs Network I/O: The Architectural Split
One of the most persistent misconceptions is that all asynchronous Node.js operations work the same way under the hood.
In reality, Node.js handles file system operations and network sockets through completely separate execution pipelines.
Why File I/O Requires Background Threads
Consider this common code:
import fs from 'node:fs';
fs.readFile('/var/log/app.log', (err, data) => {
console.log('File read complete:', data.length);
});
Why does this asynchronous call require background threads?
Because across most operating systems (particularly Linux), there is no truly non-blocking API for regular disk file operations.
While network sockets can easily report whether incoming data is ready to be read, standard file systems on physical disk drives do not provide a universal, non-blocking asynchronous event interface.
If Node.js called read() on a disk file from the main V8 thread, and the hard drive heads had to seek across physical disk platters or wait for a network-mounted volume (NFS/EFS), the main thread would freeze for milliseconds. No HTTP requests could be handled during that pause.
To prevent this freeze:
-
fs.readFile()packages the file descriptor and buffer into a work request (uv_work_t). - libuv places the request into an internal task queue.
- An idle worker thread from the libuv threadpool picks up the request.
- The worker thread calls the blocking C system call
read()entirely in the background. - Once the bytes are read into memory, the worker thread posts a completion event back to the event loop.
- The main thread picks up the completion event during the Poll phase and invokes your JavaScript callback.
Why Network I/O Never Touches the Threadpool
Now look at an incoming HTTP request or an outgoing API fetch:
import http from 'node:http';
http.get('http://api.example.com/users', (res) => {
res.on('data', (chunk) => {
console.log('Received packet chunk');
});
});
Does this operation take a thread from the libuv worker threadpool?
No. It consumes zero worker threads.
Network sockets are represented by file descriptors that support non-blocking mode. When Node.js creates a socket, it flags the socket as non-blocking:
// libuv sets O_NONBLOCK on socket file descriptors
fcntl(fd, F_SETFL, O_NONBLOCK);
Then, libuv registers the socket descriptor with the OS kernel's event demultiplexer (epoll_ctl on Linux).
The kernel's network stack monitors the physical Network Interface Card (NIC). When packets arrive over the wire, the hardware fires an interrupt, the kernel fills an internal buffer, and epoll marks the socket descriptor as readable.
libuv simply asks the kernel: "Which of our 20,000 registered sockets have data ready right now?"
The kernel returns the ready list instantly. Node.js reads the bytes and executes your callback, all without burning background worker threads.
This fundamental distinction explains why a single Node.js instance can easily hold 50,000 idle WebSocket connections open, yet simultaneously struggle if 10 threads attempt heavy cryptographic hashing at the same time.
The 6 Discrete Phases of the libuv Event Loop
Many developers picture the event loop as a basic JavaScript array of functions:
// The false mental model
while (queue.length > 0) {
const callback = queue.shift();
callback();
}
In the actual C implementation of libuv (src/unix/core.c), the event loop is a structured, six-phase state machine. Each phase maintains its own queue of pointers to C callbacks and operates on strict rules.
Here is what happens during each tick of the loop:
1. Timers Phase
The loop begins by checking for expired timers scheduled via setTimeout() and setInterval().
libuv stores active timers in a binary min-heap sorted by expiration timestamp. During this phase, libuv compares the current system clock with the root of the heap. It loops through all timers whose target time has arrived, executing their associated JavaScript callbacks in order.
2. Pending Callbacks (I/O Callbacks Phase)
This phase executes deferred system callbacks that were postponed from the previous loop iteration.
For instance, if a TCP socket connection attempt fails with an operating system error like ECONNREFUSED, some UNIX platforms report the error asynchronously. libuv defers reporting the error until this phase to give the system time to stabilize.
3. Idle / Prepare Phase
These two phases are used internally by libuv for housekeeping and synchronization before polling. Developer code cannot schedule callbacks directly into these queues.
4. Poll Phase: The Heart of Node.js
The Poll phase is where Node.js spends the vast majority of its operational life.
The Poll phase performs two critical responsibilities:
- It calculates how long it should block and wait for new I/O events.
- It processes ready events from the kernel demultiplexer (
epoll_wait).
If the poll queue contains events (such as incoming network packets or completed file read operations from the threadpool), libuv iterates through them and executes their JavaScript callbacks synchronously.
What happens if the poll queue is empty?
- If callbacks are scheduled in the
setImmediate()queue (the Check phase), Node.js does not wait. It ends the Poll phase immediately and proceeds to Check. - If timers are pending in the Timers phase, libuv blocks only for the exact amount of time remaining until the earliest timer expires.
- If no timers and no
setImmediatecalls are pending, libuv blocks indefinitely until the OS kernel signals that a network packet has arrived!
5. Check Phase (setImmediate)
The Check phase exists exclusively to execute callbacks registered via setImmediate().
Because the Check phase runs immediately after the Poll phase, setImmediate() callbacks are guaranteed to run right after incoming I/O events are processed, before the loop cycles back to check timers again.
6. Close Callbacks Phase
If a socket or stream is abruptly destroyed (such as socket.destroy() or socket.on('close')), its cleanup handler executes during this final phase, freeing allocated C structures before the next tick begins.
The Microtask Interceptors: process.nextTick vs Promises
Notice that neither process.nextTick() nor Promise.then() appeared in the six libuv phases above.
Why?
Because Microtasks are not part of the libuv event loop at all. They belong to the V8 engine layer.
Microtasks do not wait for the event loop to advance to a specific phase. Instead, they act as high-priority interceptors that run immediately after the current operation finishes, between every single callback, and at every phase boundary.
The Two Microtask Queues
Node.js manages two distinct microtask queues in memory:
-
The
process.nextTickQueue: Maintained internally by Node.js. It holds callbacks scheduled viaprocess.nextTick(). -
The Promise Microtask Queue: Maintained by the V8 engine. It holds resolved Promise callbacks (
.then(),.catch(),awaitcontinuations, andqueueMicrotask()).
The Strict Priority Ladder
Whenever the V8 JavaScript call stack empties:
- Node.js processes the
nextTickqueue until it is completely drained. - Node.js processes the Promise microtask queue until it is completely drained.
- Only after both queues are empty does libuv resume executing the active phase of the event loop.
Consider this classic interview puzzle:
console.log("1: Synchronous");
setTimeout(() => {
console.log("2: setTimeout");
}, 0);
setImmediate(() => {
console.log("3: setImmediate");
});
Promise.resolve().then(() => {
console.log("4: Promise Microtask");
});
process.nextTick(() => {
console.log("5: process.nextTick");
});
console.log("6: Synchronous End");
Here is the exact execution trace:
-
console.log("1: Synchronous")executes on the call stack. -
setTimeout(..., 0)registers a timer in libuv's min-heap. -
setImmediate(...)queues a callback in libuv's Check phase queue. -
Promise.resolve().then(...)queues a callback in the V8 Promise microtask queue. -
process.nextTick(...)queues a callback in thenextTickqueue. -
console.log("6: Synchronous End")executes on the call stack. - The call stack is now empty. Before the event loop can proceed, the microtask interceptor runs:
- It checks the
nextTickqueue: prints"5: process.nextTick". - It checks the Promise queue: prints
"4: Promise Microtask".
- It checks the
- The event loop now begins. Depending on machine speed and process startup latency, the Timers phase or Check phase executes next, printing either 2 or 3.
The Starvation Hazard
Because process.nextTick drains completely before any event loop phase can continue, recursive nextTick calls will completely freeze your server:
function starveLoop() {
process.nextTick(starveLoop); // Danger: Infinite recursion in microtask queue
}
starveLoop();
In this scenario, the Poll phase is never reached. Incoming HTTP requests, database responses, and file read operations are starved indefinitely. The server is completely paralyzed without throwing an error.
Threadpool Starvation: The Silent Performance Killer
Now let us examine what happens when developers run CPU-intensive tasks on Node.js without realizing they are sharing a tiny worker pool.
By default, the libuv threadpool size is set to 4 threads:
UV_THREADPOOL_SIZE = 4
These 4 threads are shared across:
- All
fsfile system calls (fs.readFile,fs.writeFile,fs.stat) - Asynchronous cryptography (
crypto.pbkdf2,crypto.scrypt,crypto.randomBytes) - Compression routines (
zlib.gzip,zlib.brotliCompress) - DNS resolution via
dns.lookup(which uses the blocking POSIXgetaddrinfo()C library)
The Latency Spike Experiment
Imagine a Node.js authentication service that hashes passwords using crypto.pbkdf2:
import crypto from 'node:crypto';
function hashPassword(password) {
const start = Date.now();
crypto.pbkdf2(password, 'salt', 100_000, 64, 'sha512', () => {
console.log(`Hash finished in ${Date.now() - start}ms`);
});
}
// Five users attempt to log in at the exact same millisecond:
hashPassword('pass1');
hashPassword('pass2');
hashPassword('pass3');
hashPassword('pass4');
hashPassword('pass5');
If you run this code, you will observe an alarming result:
Hash finished in 82ms // Request 1 (Thread 1)
Hash finished in 84ms // Request 2 (Thread 2)
Hash finished in 85ms // Request 3 (Thread 3)
Hash finished in 86ms // Request 4 (Thread 4)
Hash finished in 168ms // Request 5 (Queued!)
Requests 1 through 4 finish in roughly 84 milliseconds.
Request 5 takes twice as long (168 ms), even though all five requests do the exact same amount of work!
Why?
Because the threadpool only has 4 threads. Requests 1 through 4 monopolized every worker thread simultaneously. Request 5 sat completely frozen in the libuv task queue, waiting for one of the first four threads to finish before it could even begin computing.
Worse, while those 4 threads were computing hashes:
- Any concurrent
fs.readFile()on your server had to wait in the queue. - Any
dns.lookup()domain resolution had to wait in the queue. - Your entire backend experienced sudden, mysterious latency spikes.
The Production Fix
You can increase the size of the worker threadpool up to 128 threads by setting the UV_THREADPOOL_SIZE environment variable:
UV_THREADPOOL_SIZE=16 node server.js
Crucial Caveat: You must set this environment variable before the Node.js process starts. Setting process.env.UV_THREADPOOL_SIZE = 16 inside your JavaScript code will not work, because libuv initializes its threadpool during C startup before your script runs!
Modern Concurrency: Worker Threads & SharedArrayBuffer
Increasing UV_THREADPOOL_SIZE helps with I/O and native cryptography, but what if you need to run heavy JavaScript calculations (like parsing large JSON dumps, generating PDF reports, or running machine learning algorithms)?
JavaScript still executes on the single main V8 thread. Increasing the libuv threadpool will not help.
This is why Node.js introduced the worker_threads module.
Worker threads provide true CPU multi-threading inside Node.js by spinning up separate V8 instances on independent operating system threads.
Method 1: MessagePort & Structured Cloning
The standard way to communicate between worker threads is via parentPort.postMessage():
// main.js
import { Worker } from 'node:worker_threads';
const worker = new Worker('./heavy-task.js', {
workerData: { items: [1, 2, 3, 4, 5] }
});
worker.on('message', (result) => {
console.log('Calculation complete:', result);
});
Under the hood, postMessage uses the HTML Structured Clone Algorithm.
It serializes the JavaScript object, copies the data across the thread boundary, and deserializes a fresh copy inside the worker thread's private memory heap.
This approach provides total memory isolation and zero risk of race conditions. However, if you pass a 500-megabyte dataset between threads, the serialization and copying overhead will consume substantial CPU time and memory.
Method 2: Zero-Copy Concurrency with SharedArrayBuffer & Atomics
For maximum performance, modern Node.js allows multiple worker threads to point directly to the exact same physical memory address using SharedArrayBuffer:
// main.js
import { Worker } from 'node:worker_threads';
// Allocate 1 kilobyte of shared physical RAM
const sharedBuffer = new SharedArrayBuffer(1024);
const sharedInts = new Int32Array(sharedBuffer);
const worker = new Worker('./worker.js', {
workerData: { sharedBuffer }
});
// Wait for worker to signal update
Atomics.wait(sharedInts, 0, 0);
console.log('Worker updated index 0 to:', sharedInts[0]);
In the worker thread:
// worker.js
import { workerData } from 'node:worker_threads';
const sharedInts = new Int32Array(workerData.sharedBuffer);
// Mutate memory directly with atomic safety
Atomics.store(sharedInts, 0, 42);
Atomics.notify(sharedInts, 0, 1); // Wake up waiting main thread
With SharedArrayBuffer:
- Zero data is copied or serialized.
- Both the main thread and the worker thread read and write to the exact same RAM bytes.
- The
Atomicsobject provides thread-safe operations (load,store,add,wait,notify), preventing data races and corrupted memory states.
This transforms Node.js from a simple event-driven script runner into a high-concurrency systems platform capable of high-throughput numerical processing.
Architectural Takeaways for Production Backends
When you step back and look at the entire machine, the true architecture of Node.js becomes clear:
- JavaScript is single-threaded; Node.js is not. The V8 engine executes user code on a single thread, while libuv and the OS kernel handle concurrency with multiple native background threads.
-
Network I/O is non-blocking at the kernel level. Sockets rely on
epoll,kqueue, andIOCP, consuming zero worker threads and enabling immense network concurrency. - File I/O and crypto rely on the libuv threadpool. A default pool of 4 threads means concurrent file reads or hashing operations can easily cause silent queuing bottlenecks.
-
Microtasks take absolute priority.
process.nextTick()and Promise callbacks run between every phase boundary. Recursive nextTicks will starve the event loop completely. -
Worker Threads unlock true CPU parallelism. For heavy CPU workloads, offload calculations to dedicated
worker_threadsusing structured messaging or lock-freeSharedArrayBuffermemory.
Node.js achieved its dominance not because single-threaded execution is inherently superior, but because its creators built a brilliant bridge between an intuitive, single-threaded developer model and the multi-threaded, asynchronous power of modern operating systems.
When you architect your next Node.js microservice or debug a mysterious production latency spike, which layer of the runtime usually causes the most surprises in your codebase? And how do you balance worker thread pools against external worker queues like Redis or BullMQ in your infrastructure?
References & Technical Standards:
- libuv Official Architecture & Design Guide
- Node.js Official Documentation: The Event Loop, Timers, and process.nextTick()
- Node.js Core Repository: C++ libuv Integration & fs Implementation
- Google V8 Engine Documentation: Memory Heap & Concurrency
- POSIX Standards: Non-blocking I/O and epoll(7) Linux Programmer's Manual






Top comments (1)
ok that ps -eLf detail got me, never once