This article is published in English.
Node.js Concurrency Explained: libuv, the Event Loop, and Thread Pool
Learn how Node.js uses libuv's OS primitives and worker thread pool to handle async I/O, plus common thread pool pitfalls and tuning tips.
Almost every developer picks up the phrase "Node.js is single-threaded" within their first week of learning the platform. Yet in practice, a single Node.js process can read hundreds of files simultaneously, look up thousands of DNS records, and juggle tens of thousands of open database connections while continuing to execute code without pausing.
If JavaScript itself only runs on one thread, how does a Node.js server keep responding to requests while it's pulling a multi-gigabyte file off a spinning disk?
The mechanism behind this is libuv, a C library purpose-built for Node.js that manages asynchronous, non-blocking input and output.
Getting a real grasp of how libuv offloads work away from the JavaScript thread isn't just theoretical knowledge. It's what explains why a database query behaves differently than a hashing operation performance-wise, why tweaking a single environment variable can noticeably change how fast (or slow) your production API responds, and how you can spot and avoid hidden bottlenecks in your services.
What Is libuv?
Node.js isn't a single monolithic engine — it's a stack of several cooperating layers:
+-------------------------------------------------------------+
| Your Application |
+-------------------------------------------------------------+
| Node.js Core (JS / C++) |
+------------------------------+------------------------------+
| V8 Engine (Google) | libuv |
| (Executes JavaScript) | (Event Loop & Async I/O) |
+------------------------------+------------------------------+
| Operating System Kernel |
+-------------------------------------------------------------+
- V8 (Google): This engine compiles and runs your JavaScript. It has exactly one call stack, and it executes code sequentially, on one thread only.
- libuv: A cross-platform library written in C that's responsible for the event loop, a pool of worker threads, filesystem access, timers, spawning child processes, and monitoring network sockets.
When people describe Node.js as single-threaded, what they really mean is that the JavaScript execution context runs on a single main thread. libuv itself, however, is written in C and is inherently multi-threaded. It leans on whatever low-level facilities the operating system exposes to run tasks concurrently, all without blocking JavaScript execution.
Two Ways libuv Handles Asynchronous Work
It's common to assume libuv routes every asynchronous operation to a background thread. That's not quite right — libuv actually splits work between two separate strategies, depending on what kind of task it is:
- Native, non-blocking OS facilities (for network input and output)
- libuv's internal thread pool (for filesystem access, DNS lookups, and crypto)
Understanding this split is arguably the single most useful mental model for reasoning about Node.js backend performance.
Incoming Async Task
│
├── Is it Network I/O? (TCP/UDP, HTTP sockets)
│ └──> Handled directly by OS Kernel mechanisms (epoll / kqueue / IOCP)
│ (Zero worker threads used)
│
└── Is it File I/O, DNS lookup, or CPU-bound crypto/compression?
└──> Handled by libuv Thread Pool (4 threads by default)
1. Network Input and Output: Operating System Primitives
Modern operating systems ship with dedicated, non-blocking APIs for handling network sockets:
- epoll on Linux
- kqueue on macOS and the BSD family
- IOCP (Completion Ports for input and output) on Windows
When a Node.js app opens a TCP listener or fires off an outbound HTTPS request, libuv does not hand that off to a worker thread. Instead, it registers the socket's file descriptor directly with the OS kernel, effectively requesting to be notified once there's incoming data on that socket, or once it's ready to accept more writes.
From that point on, libuv simply waits. It's the operating system kernel that watches the network hardware.
Once packets actually arrive at the network interface, the kernel raises an event. libuv picks this up during the polling stage of its event loop, then queues the corresponding JavaScript callback for execution. V8 eventually dequeues it and runs it on the main thread.
Since no worker thread is sitting around waiting for bytes to show up on the wire, a single Node.js process can comfortably manage tens of thousands of slow or idle connections while consuming very little memory.
2. File Input, Output and System Operations: The Worker Thread Pool
Given that network sockets can be handled without blocking at the kernel level, you might wonder why file reads and writes can't work the same way.
The reason is that most operating systems have no true non-blocking API for filesystem access. On POSIX-based systems like Linux and macOS, ordinary file operations block whichever thread calls them until the storage device actually returns the requested data.
If Node.js tried to perform a file read directly on the main JavaScript thread, the whole runtime would stall until the disk spun up, retrieved the relevant blocks, and handed back the bytes. During that entire pause, no other incoming HTTP request could be served.
To sidestep this problem, libuv maintains an internal worker thread pool.
Here's what happens when your code calls fs.readFile():
- The JavaScript call travels down through Node's internal bindings into libuv.
- libuv packages the file-read request as a unit of work and pushes it onto an internal work queue.
- One of the background threads in the pool pulls this request off the queue.
- That thread then performs the actual blocking system call —
read()orwrite()— safely, away from the main thread. - Once the read finishes, the worker thread signals the event loop through an inter-thread notification mechanism.
- The event loop then schedules the corresponding JavaScript callback to run on the main thread, passing along the resulting buffer.
What Actually Runs on the Thread Pool?
Four broad categories of work rely on the libuv thread pool:
- File system calls: every asynchronous method under
fs, such asfs.readFile,fs.stat, andfs.writeFile. - DNS lookups: specifically
dns.lookup(), which relies on the blocking C functiongetaddrinfo(). By contrast,dns.resolve()skips the thread pool entirely and talks to the network directly through non-blocking calls. - Expensive cryptographic operations: functions like
crypto.pbkdf2(),crypto.scrypt(), and key-generation routines. - Compression routines: the asynchronous
zlibmethods, such aszlib.gzip().
Watching the Thread Pool Work
You can confirm that libuv relies on a background pool, and see its default size, with a short experiment:
// thread-pool-test.js
const crypto = require('crypto');
const start = Date.now();
const ITERATIONS = 6;for (let i = 1; i <= ITERATIONS; i++) {
crypto.pbkdf2('password123', 'salt-value', 100000, 64, 'sha512', () => {
const elapsed = Date.now() - start;
console.log(`Task ${i} completed in ${elapsed}ms`);
});
}
crypto.pbkdf2() is a good test case because it performs intentionally heavy CPU work to derive a password hash, and that work is dispatched to the thread pool.
Run the script from your terminal:
node thread-pool-test.js
The output will look something like this:
Task 2 completed in 218ms
Task 1 completed in 220ms
Task 4 completed in 224ms
Task 3 completed in 226ms
Task 5 completed in 435ms
Task 6 completed in 437ms
Why Do the Last Two Tasks Take Twice as Long?
Look closely at the timings. The first four tasks all finish at roughly the same moment, around 220ms. But the fifth and sixth tasks take close to 435ms — nearly double.
The reason is that libuv's thread pool ships with a default size of 4 threads.
As soon as the loop begins, the first four tasks grab all four available worker threads. The fifth and sixth tasks then sit in libuv's internal queue, waiting. Only once one of the original four finishes and frees up a thread can the queued tasks start executing.
Adjusting Pool Size With UV_THREADPOOL_SIZE
You can change how many worker threads libuv spins up by setting the UV_THREADPOOL_SIZE environment variable before starting the Node.js process. It accepts values from 1 up to 128.
Try the same script again, this time requesting a pool of 8 threads:
# On Linux / macOS:
UV_THREADPOOL_SIZE=8 node thread-pool-test.js
# On Windows (PowerShell):
$env:UV_THREADPOOL_SIZE=8; node thread-pool-test.js
The results look different now:
Task 1 completed in 240ms
Task 3 completed in 242ms
Task 2 completed in 245ms
Task 6 completed in 249ms
Task 4 completed in 250ms
Task 5 completed in 252ms
With enough worker threads available, all six tasks execute concurrently instead of queueing.
Important limitation: you cannot resize the pool from inside your script by writing
process.env.UV_THREADPOOL_SIZE = 8. libuv reads and locks in this value before your JavaScript code ever runs, so the environment variable has to be set at the shell level or by whatever process manager launches Node, not from within the application itself.
A Common Production Problem: Threads Shared Across Unrelated Work
Since file operations, DNS lookups, and cryptographic work all draw from the same pool of four threads by default, heavy usage in one category can quietly slow down something completely unrelated.
Picture this sequence of events:
- A wave of logins triggers several concurrent calls to
crypto.pbkdf2to verify passwords. - All four libuv threads are now fully busy computing password hashes.
- At that same moment, some other part of the app calls
fs.readFile()to load an email template, or callsdns.lookup()to resolve your database's hostname. - Both of those operations have to wait their turn in the queue.
Reading a file barely uses any CPU, but it still gets delayed because every worker thread is tied up with hashing. From the outside, it looks like your file access or database connectivity has slowed down, when the real culprit is contention for libuv's threads.
How to Relieve Thread Pool Contention:
- Give the pool more threads: if your service does a lot of file I/O or cryptographic work, bumping
UV_THREADPOOL_SIZEup to 16 or 32 can reduce contention, assuming the underlying machine has the CPU headroom to support it. - Move custom CPU-bound work off the pool: for tasks you control, like generating reports or processing images, don't try to route them through libuv. Instead, use the
worker_threadsmodule, which spins up separate V8 instances on their own OS threads. - Steer clear of
dns.lookup()where you can: favordns.resolve4(), or set up connection pools that use explicit IP addresses, so that network name resolution doesn't eat into libuv's limited worker slots.
Where Developers Commonly Go Wrong
Mistake One: Assuming async/await Automatically Offloads Work
Prefixing a function with async does not spawn a background thread for it. async/await is just cleaner syntax layered on top of Promises. If the function body contains a synchronous loop or a heavy computation, that code still runs directly on the main JavaScript thread, and it will still block your server while it executes.
Mistake Two: Mixing Up the libuv Thread Pool With worker_threads
- libuv's thread pool is managed internally by native C code. It handles built-in operations such as
fs,crypto, andzlib. You have no way to push your own arbitrary JavaScript functions into this pool. - The
worker_threadsmodule, available since Node.js 10.5, is a JavaScript-level API. It lets you run your own code in parallel, with each worker getting its own independent V8 engine and event loop.
Mistake Three: Making the Thread Pool Too Large
It's tempting to just set UV_THREADPOOL_SIZE=128 everywhere and assume more is better. But threads carry real cost. Each one needs memory for its own execution stack, and once you have hundreds of threads competing for only two or four CPU cores, the operating system starts burning significant time just switching between them.
A reasonable starting point is to size the pool to match your logical CPU core count when the workload is CPU-bound, such as crypto or compression, or to use two to four times the core count when the work mostly waits on disk I/O.
Summary
The real trick behind Node.js is not that it avoids concurrency altogether, but that it wraps low-level threading details inside a simple, event-driven programming model.
- JavaScript execution stays single-threaded: application logic runs one step at a time, which sidesteps race conditions and the need for locks.
- Network operations go through OS-level mechanisms in libuv: sockets are managed by the kernel's polling systems, such as
epoll,kqueue, orIOCP, and consume no worker threads at all. - File access, DNS lookups, and cryptographic work rely on the thread pool: four background C threads absorb these blocking calls so the main thread stays free to keep accepting new requests.
Once you know which of these two paths a given operation takes, you're in a much better position to track down performance issues, size your servers correctly, and build backend services that hold up well under heavy load.