Suggest an editImprove this articleRefine the answer for “What is libuv and how does it enable async i/o in Node.js?”. Your changes go to moderation before they’re published.Approval requiredContentWhat you’re changing🇺🇸EN🇺🇦UAPreviewTitle (EN)Short answer (EN)**libuv** is a C library that gives Node.js its event loop, thread pool, and async I/O by wrapping epoll, kqueue, and IOCP into one cross-platform API. Network I/O uses OS polling directly; file I/O, DNS, and crypto use a thread pool of 4 threads by default. ```js fs.readFile('./file.txt', (err, data) => console.log(data.length)); // async via libuv thread pool console.log('runs first'); // JS thread never blocked ``` **Key:** libuv is why Node.js handles thousands of connections on one thread without blocking.Shown above the full answer for quick recall.Answer (EN)Image**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_SIZE` first ### Quick example ```javascript 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 bytes ``` `fs.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, zlib ``` The 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 not `dns.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](/questions/nodejs-event-loop). ### Event loop phases libuv's event loop runs in phases on every iteration. With `UV_RUN_DEFAULT`, the cycle is: 1. **Timers** - runs `setTimeout` and `setInterval` callbacks whose threshold has passed 2. **Pending callbacks** - I/O callbacks deferred from the previous iteration 3. **Idle / Prepare** - internal libuv use 4. **Poll** - blocks waiting for I/O events; dispatches ready I/O callbacks 5. **Check** - runs `setImmediate` callbacks 6. **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. ```javascript 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 alive ``` When 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](/questions/nodejs-process-lifecycle). ### Common mistakes **Using sync crypto in request handlers** ```javascript // 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: ```javascript 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: ```javascript // 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** ```javascript // 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.readFile` or `res.sendFile` read 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-redis` streams 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.pbkdf2` at login scale requires raising `UV_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 ```javascript 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 chars ``` `readFile` 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 ```javascript 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.js ``` Hit 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 ```javascript 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 + timer ``` libuv 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.For the reviewerNote to the moderator (optional)Visible only to the moderator. Helps review go faster.