What is libuv and how does it enable async i/o in Node.js?
libuv is a cross-platform C library that gives Node.js its event loop, thread pool, and async I/O interfaces by wrapping OS mechanisms like epoll on Linux, kqueue on macOS, and IOCP on Windows into one unified API.
Theory
TL;DR
- libuv sits between Node.js JS code and the OS, hiding platform differences behind one API
- The event loop polls OS handles for I/O completion without spinning a thread per request
- Blocking operations (file I/O, DNS, crypto) go to a thread pool with 4 threads by default
- Network I/O (TCP, UDP) skips the thread pool entirely and uses OS async polling directly
- If you see slow responses under concurrent crypto or file load, check
UV_THREADPOOL_SIZEfirst
Quick example
const fs = require('fs'); // fs uses libuv under the hood
fs.readFile('data.txt', (err, data) => {
if (err) throw err;
console.log('File read:', data.length, 'bytes'); // fires after I/O completes
});
console.log('This runs first'); // event loop continues immediately
// Output:
// This runs first
// File read: 1048576 bytesfs.readFile hands the work to libuv's thread pool and returns immediately. The callback fires later when the OS finishes reading. The JS thread never waits.
How libuv fits into Node.js
Node.js APIs like fs, net, and crypto call C++ bindings, which call libuv functions. libuv's event loop (uv_run) then takes one of two paths: registers the operation with the OS poller for network I/O, or submits it to the thread pool for blocking operations.
Node.js (JS / V8)
|
C++ Bindings
|
libuv event loop (uv_run)
| |
OS pollers Thread pool
epoll / kqueue (4 threads by default)
IOCP (Windows) fs, dns, crypto, zlibThe JS thread loops, checks for completed work, and dispatches callbacks. It never sits idle waiting for a disk or network response.
Thread pool vs. OS polling
Not all async operations take the same path. Understanding the split explains most Node.js performance surprises.
OS polling (no threads involved):
- TCP/UDP sockets:
http.get,net.connect,net.createServer - Pipes, signals, timers
Thread pool (4 threads by default):
- File system:
fs.readFile,fs.writeFile,fs.stat - Crypto:
crypto.pbkdf2,crypto.scrypt - DNS:
dns.lookup(but notdns.resolve) - Compression:
zlib.gzip,zlib.deflate
Network I/O goes through epoll/kqueue/IOCP without allocating a thread per connection. That is why Node.js handles thousands of concurrent HTTP connections on a single thread. For context on how callbacks flow through phases, see how the Node.js event loop works.
Event loop phases
libuv's event loop runs in phases on every iteration. With UV_RUN_DEFAULT, the cycle is:
- Timers - runs
setTimeoutandsetIntervalcallbacks whose threshold has passed - Pending callbacks - I/O callbacks deferred from the previous iteration
- Idle / Prepare - internal libuv use
- Poll - blocks waiting for I/O events; dispatches ready I/O callbacks
- Check - runs
setImmediatecallbacks - Close callbacks - cleanup for closed handles like
socket.destroy()
The poll phase is where the loop actually pauses. If there are pending I/O operations, it waits here until the OS signals completion or a timer is about to fire. setImmediate runs in the check phase, so it always fires after any I/O callbacks resolved in the same poll iteration.
Handles and requests
libuv uses two internal abstractions worth knowing at the senior level.
Handles are long-lived objects: a TCP server, a timer, a file watcher. They persist across loop iterations and keep the process alive.
Requests are one-shot operations: a file read, a write to a socket, a DNS lookup. They complete once and disappear.
const net = require('net');
const server = net.createServer(() => {}); // Handle - lives until server.close()
server.listen(3000);
const fs = require('fs');
fs.readFile('./config.json', cb); // Request - one-time, does not keep process aliveWhen debugging "why is my script hanging," call process._getActiveHandles(). A TCP server or a timer you forgot to clear will show up there. For more on this pattern, see Node.js process lifecycle and handles.
Common mistakes
Using sync crypto in request handlers
// Blocks every other request for ~300ms
app.post('/login', (req, res) => {
const hash = crypto.pbkdf2Sync(req.body.password, salt, 100000, 512, 'sha512');
// While this line runs, zero other requests are processed
res.send('ok');
});pbkdf2Sync holds the JS thread until it finishes. With 10 concurrent logins, they process one by one. Use the async version, which goes to the thread pool:
crypto.pbkdf2(req.body.password, salt, 100000, 512, 'sha512', (err, hash) => {
res.send('ok');
});Ignoring thread pool exhaustion
Four threads sounds fine until you run password hashing at scale. Each pbkdf2 call takes 200-300ms. With the default pool, the 5th concurrent request queues behind the first four. I've seen this add two seconds to p99 latency on a service that seemed otherwise healthy. Bump UV_THREADPOOL_SIZE before the process starts:
// As an environment variable before starting node:
// UV_THREADPOOL_SIZE=32 node server.js
// Or at the very top of your entry file, before any require()
process.env.UV_THREADPOOL_SIZE = '32';Setting UV_THREADPOOL_SIZE too late
// WRONG - pool may already be initialized by the time this runs
setTimeout(() => {
process.env.UV_THREADPOOL_SIZE = '64';
}, 0);The pool initializes on first use. Any require of crypto, dns, or fs before you set the variable locks in the default 4 threads.
Treating event loop blocks as V8 bugs
When an API "freezes randomly," developers often look at garbage collection or V8 first. But V8 runs JavaScript; libuv runs the event loop. The actual cause is usually one of: a saturated thread pool delaying callbacks, a long sync operation blocking the JS thread, or a handle keeping the loop from draining. Use clinic.js or 0x to get a flamegraph. Add console.log(process.hrtime.bigint()) around suspicious callbacks to see where the time goes.
Real-world usage
- Express.js routes using
fs.readFileorres.sendFileread from disk via the libuv thread pool; the TCP response goes through OS polling - Next.js uses chokidar for hot module replacement; chokidar wraps libuv file watcher handles
node-redisstreams TCP data through libuv handles without touching the thread pool- AWS SDK calls to S3 or DynamoDB are network I/O, handled by OS polling directly
- High-volume password hashing with
crypto.pbkdf2at login scale requires raisingUV_THREADPOOL_SIZE
Follow-up questions
Q: What happens to thread pool operations when all 4 threads are busy?
A: New requests queue inside libuv and wait until a thread frees. Latency grows proportionally to queue depth. The 5th concurrent pbkdf2 call takes roughly 2x longer than the first four because it sits in line.
Q: Why does network I/O skip the thread pool?
A: TCP and UDP are natively async at the OS level. epoll, kqueue, and IOCP poll many sockets without blocking a thread. libuv registers the socket with the OS poller and gets notified when data arrives. No threads needed for this path.
Q: In what order do event loop phases run?
A: Timers, pending callbacks, idle/prepare, poll, check, close callbacks. The poll phase blocks waiting for I/O. setImmediate runs in check, so it always fires after I/O callbacks from the same poll iteration - not before them.
Q: How do you debug a libuv loop that won't exit?
A: Call process._getActiveHandles() and process._getActiveRequests(). A timer you forgot to clear or a TCP socket left open will appear there. In production, clinic.js gives a flamegraph of event loop utilization per phase.
Q (senior): What is the difference between UV_RUN_DEFAULT, UV_RUN_ONCE, and UV_RUN_NOWAIT?
A: UV_RUN_DEFAULT runs until no active handles or requests remain, blocking in the poll phase as needed. UV_RUN_ONCE runs one iteration and blocks in poll at most once. UV_RUN_NOWAIT runs one iteration but never blocks in poll, even with no ready events. Node uses UV_RUN_DEFAULT. Embedding libuv in Electron or Deno's compatibility layer sometimes requires UV_RUN_NOWAIT to share time with the host loop without stalling it.
Examples
Basic: Seeing the non-blocking execution order
const fs = require('fs');
console.log('1: before readFile');
fs.readFile('./package.json', 'utf8', (err, data) => {
if (err) throw err;
console.log('3: file size:', data.length, 'chars');
});
console.log('2: after readFile call');
// Output:
// 1: before readFile
// 2: after readFile call
// 3: file size: 432 charsreadFile registers a request with libuv and returns without waiting. The JS thread reaches line 2 before the disk read completes. When the OS signals completion, libuv queues the callback and the event loop runs it in the next poll phase. This ordering is the foundation of every async pattern in Node.js.
Intermediate: Thread pool exhaustion in an HTTP server
const crypto = require('crypto');
const http = require('http');
// UV_THREADPOOL_SIZE = 4 by default
// 5 concurrent pbkdf2 calls per request saturates the pool
const server = http.createServer((req, res) => {
const start = Date.now();
let count = 0;
for (let i = 0; i < 5; i++) {
crypto.pbkdf2('secret', 'salt', 100000, 512, 'sha512', () => {
count++;
if (count === 5) {
res.end(`Took ${Date.now() - start}ms`);
}
});
}
});
server.listen(3000);
// First 4 pbkdf2 calls run in parallel across 4 threads
// 5th queues and waits - total time roughly doubles
// Fix: UV_THREADPOOL_SIZE=8 node server.jsHit this endpoint and you will see the timing jump on the 5th task. Doubling the pool size cuts the wait proportionally. This is the most common production issue directly tied to libuv's thread pool limit.
Advanced: Handle lifecycle and process exit timing
const net = require('net');
const fs = require('fs');
// A handle keeps the process alive
const server = net.createServer(() => {});
server.listen(4000);
// A request does not keep the process alive on its own
fs.readFile('./data.txt', () => {
console.log('file done');
});
// Without server.close(), this process never exits.
// The TCP handle is still active in libuv's loop.
setTimeout(() => {
server.close(() => {
console.log('server closed - process can now exit');
});
}, 2000);
console.log('active handles:', process._getActiveHandles().length); // 2: server + timerlibuv keeps uv_run going as long as there are active handles. The file read is a request, not a handle, so it does not block exit. The TCP server is a handle. Close it and the loop drains. Knowing this distinction is what separates "Node.js exits too early" debugging from "Node.js never exits" debugging.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.