Understanding libuv and JS Asynchronous I/O

Learn how libuv powers Node.js asynchronous I/O. Understand the event loop, thread pool, and how JavaScript handles file system, network, and DNS operations without blocking.

8 min read

libuv is the C library that gives Node.js its asynchronous superpowers. When JavaScript code calls fs.readFile() or http.createServer(), it is libuv that handles the actual I/O behind the scenes, keeping the main JavaScript thread free to run other code.

Originally built for Node.js in 2011, libuv abstracts away the differences between operating systems. On Linux it uses epoll. On macOS it uses kqueue. On Windows it uses IOCP. Your JavaScript code never needs to care.

Where libuv Sits in Node.js

Node.js architecture: V8, libuv, and the thread pool

Your JavaScript calls a Node.js API like fs.readFile(). Node.js delegates the work to libuv. For network I/O, libuv uses the operating system's native non-blocking primitives. For file I/O, libuv sends the work to a thread pool because operating systems lack reliable cross-platform non-blocking file APIs.

Network I/O: Truly Non-Blocking

When you create an HTTP server in Node.js, libuv registers the server socket with the operating system's I/O notification mechanism:

javascriptjavascript
const http = require("http");
 
const server = http.createServer((req, res) => {
  res.end("Hello from Node.js");
});
 
server.listen(3000);

What happens:

  1. libuv registers the server socket with epoll (Linux), kqueue (macOS), or IOCP (Windows).
  2. The event loop enters the poll phase and waits for activity.
  3. When a client connects, the OS notifies libuv.
  4. libuv executes the JavaScript callback ((req, res) => { ... }).
  5. The callback runs. Node.js writes the response and returns to the event loop.

At no point does the JavaScript thread block waiting for a connection. The operating system does the waiting. libuv translates OS events into JavaScript callbacks.

File I/O: The Thread Pool

File system operations are different. There is no portable non-blocking file API across operating systems. libuv solves this with a thread pool:

javascriptjavascript
const fs = require("fs");
 
console.log("Before read");
 
fs.readFile("/path/to/large-file.txt", "utf8", (err, data) => {
  console.log("File read complete, size:", data.length);
});
 
console.log("After read");
texttext
Before read
After read
File read complete, size: 5242880

The sequence under the hood:

  1. fs.readFile() calls into libuv.
  2. libuv queues the read operation on the thread pool.
  3. A worker thread opens the file and reads it (blocking on that thread only).
  4. The main JavaScript thread continues executing ("After read" prints immediately).
  5. When the read completes, libuv queues the callback.
  6. The event loop picks up the callback and calls it ("File read complete" prints).

The default thread pool has 4 threads. You can change it with the UV_THREADPOOL_SIZE environment variable:

bashbash
UV_THREADPOOL_SIZE=8 node app.js

All blocking operations share this pool: file reads, file writes, DNS lookups, and CPU-intensive crypto operations. If you queue 10 file reads and have 4 threads, the first 4 start immediately and the remaining 6 wait.

The Event Loop Phases

libuv's event loop runs in phases. Each phase has a queue of callbacks. Here is the order:

libuv event loop phases in order

Timers phase: executes callbacks scheduled by setTimeout() and setInterval() whose time has elapsed.

Pending callbacks phase: executes I/O callbacks deferred from the previous iteration (mostly used internally).

Idle and Prepare phases: internal phases used by libuv. Not visible to JavaScript.

Poll phase: the heart of the event loop. Waits for new I/O events (network connections, data on sockets). If there are no timers and no active handles, the loop blocks here until a new event arrives.

Check phase: executes setImmediate() callbacks. setImmediate() is designed to run immediately after the poll phase completes.

Close callbacks phase: executes close events like socket.on("close", ...).

setImmediate vs setTimeout(fn, 0)

The phase order explains the difference between setImmediate() and setTimeout(fn, 0):

javascriptjavascript
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));

The result depends on where this code runs. Inside an I/O callback, setImmediate always runs first because the check phase comes right after the poll phase. Outside an I/O callback, the order is non-deterministic because timers can fire before the first poll.

In practice: use setImmediate() when you want a callback to run on the next iteration of the event loop. Use setTimeout(fn, 0) when you want a minimum delay. For more on the JavaScript event loop itself, see the event loop architecture guide.

The Thread Pool in Practice

Here is a demonstration of the thread pool bottleneck. Four CPU-heavy cryptographic operations with the default 4 threads:

javascriptjavascript
const crypto = require("crypto");
 
function hash(password) {
  return new Promise((resolve) => {
    crypto.pbkdf2(password, "salt", 100000, 512, "sha512", () => {
      resolve(`Done: ${password}`);
    });
  });
}
 
async function benchmark() {
  console.time("all hashes");
 
  const results = await Promise.all([
    hash("alpha"),
    hash("beta"),
    hash("gamma"),
    hash("delta"),
    hash("epsilon")  // 5th operation waits for a free thread
  ]);
 
  console.timeEnd("all hashes");
}
 
benchmark();

With 4 threads, the first 4 hashes run concurrently. The 5th waits for one to finish. If you set UV_THREADPOOL_SIZE=5, all five run at once.

For truly CPU-bound work not related to I/O, use web workers instead. The thread pool is for I/O, not computation.

Common Misconceptions

"Node.js is single-threaded." The JavaScript code runs in a single thread. But libuv's thread pool and the operating system's I/O subsystem run on separate threads. Node.js is single-threaded for your code, multi-threaded for I/O.

"All async operations are non-blocking." File system operations block a thread pool thread. If you exhaust the pool, new file reads queue up. For truly non-blocking I/O, only network operations qualify.

"setTimeout(fn, 0) runs immediately." It runs after the current operation and all I/O callbacks in the current poll phase. The minimum delay is actually 1ms in most implementations, not 0.

Rune AI

Rune AI

Key Insights

  • libuv is a C library that provides the event loop and async I/O for Node.js.
  • Network I/O is truly non-blocking using OS primitives like epoll and kqueue.
  • File system I/O uses a thread pool because OSes lack cross-platform async file APIs.
  • The event loop has distinct phases: timers, pending callbacks, idle, poll, check, close.
  • The default thread pool has 4 threads. Blocking them all stalls the entire process.
RunePowered by Rune AI

Frequently Asked Questions

Is libuv part of the JavaScript language?

No. libuv is a C library originally written for Node.js. It provides the event loop and asynchronous I/O capabilities that Node.js exposes to JavaScript through its APIs like fs.readFile and http.createServer.

Does the browser use libuv?

No. Browsers have their own event loop implementations built into the browser engine. libuv is specific to Node.js and a few other projects outside the browser.

Why does file I/O use a thread pool but network I/O does not?

Operating systems provide native non-blocking APIs for network I/O (epoll, kqueue, IOCP). There are no reliable cross-platform non-blocking APIs for file system operations, so libuv uses a thread pool to simulate asynchronous file I/O.

Conclusion

libuv is the engine beneath Node.js that makes non-blocking I/O possible. It provides the event loop that orchestrates callbacks, a thread pool for blocking operations, and cross-platform abstractions over OS-level I/O primitives. Understanding it helps you write efficient Node.js code and debug performance issues.libuv is the bridge between JavaScript's single-threaded world and the operating system's multi-threaded I/O reality. Network I/O uses native OS primitives for true non-blocking. File I/O uses a thread pool. The event loop orchestrates callbacks through distinct phases. Understanding libuv helps you write better Node.js code: avoid blocking the thread pool with too many concurrent file operations, use setImmediate() for yielding the event loop, and know that file reads are not as "async" as network reads under the hood.