libuv & the C++ Core
If V8 is the part of Node.js that runs your JavaScript, libuv is the part that lets that JavaScript talk to the operating system without blocking. It is a multi-platform C library, originally written specifically for Node.js (and now used by other projects too), that provides three things Node depends on completely: the event loop, asynchronous I/O, and a thread pool. Almost every asynchronous thing you do in Node — reading a file, accepting a connection, resolving a hostname, hashing a password — passes through libuv.
What libuv provides
Feature | What it does |
|---|---|
Event loop | The core loop that schedules and runs callbacks — see The Event Loop |
Async network I/O | TCP, UDP, pipes, using the OS's most efficient event mechanism |
Async file I/O | File operations, run on the thread pool |
Thread pool | A small pool of worker threads for work the OS cannot do async |
DNS |
|
Timers | Backing for |
Signals & child processes | OS signal handling and process spawning |
The two paths of asynchrony
This is the single most important — and most often skipped — detail about libuv. It handles asynchronous work in two fundamentally different ways, and knowing which path an operation takes explains a lot of real-world performance behaviour.
Path | Used for | Cost | Why |
|---|---|---|---|
Kernel-offloaded | Network I/O (TCP/UDP/HTTP) | No extra thread | The OS notifies libuv when a socket is ready — true async at the kernel level |
Thread-pool-offloaded | Files, DNS lookup, | One pool thread per op | The OS has no good async API for these, so libuv simulates async by running them on background threads |
Under the hood, the kernel path uses the most efficient event-notification mechanism each platform offers:
Linux —
epollmacOS / BSD —
kqueueWindows — IOCP (I/O Completion Ports)
Solaris — event ports
The thread pool
File, DNS, and CPU-bound crypto/compression work runs on libuv's thread pool, which defaults to 4 threads. You can change its size with the UV_THREADPOOL_SIZE environment variable — but it must be set before the process starts, because the pool is created once at startup.
# Allow up to 8 concurrent file/DNS/crypto/zlib operations UV_THREADPOOL_SIZE=8 node app.js
Demonstrating the pool size
Run this with UV_THREADPOOL_SIZE=2 and then =4 and watch the timing change. With a pool of 2, the four hashes finish in two waves; with 4+, they finish together.
pool.js
import crypto from 'node:crypto'
const start = Date.now()
for (let i = 0; i < 4; i++) {
crypto.pbkdf2('secret', 'salt', 1_000_000, 64, 'sha512', () => {
console.log(`Hash ${i + 1} done after ${Date.now() - start}ms`)
})
}# UV_THREADPOOL_SIZE=2 — two waves: Hash 1 done after 410ms Hash 2 done after 415ms Hash 3 done after 820ms Hash 4 done after 825ms
Where libuv sits in a full request
The round trip for a thread-pool operation
Your JS (V8)
│ fs.readFile('x', cb)
▼
Node C++ binding
│
▼
libuv ──► thread pool thread does the blocking read
│ │
│ read completes
▼ ▼
event loop ◄── libuv queues cb
│
▼
cb(err, data) runs back in V8 on the main threadTuning notes
Raise
UV_THREADPOOL_SIZEif you do heavy concurrent file/crypto/zlib work — but it does not help network throughput (that path uses no pool threads).Never run a synchronous (
*Sync) or CPU-bound operation on the main thread expecting libuv to save you — only the async APIs are offloaded.For genuinely CPU-bound JavaScript, the thread pool will not help; use Worker Threads instead.