Non-blocking I/O

intermediate libuv async thread-pool io

Non-blocking I/O is the whole reason Node exists. In simple language — when our code asks for something slow (a file, a network call), Node doesn’t sit and wait. It hands the work off and continues running other code. When the slow thing finishes, our callback gets queued up.

Blocking vs non-blocking

A blocking call freezes the thread until it returns. A non-blocking call returns immediately and notifies us later.

const fs = require("node:fs");

// Blocking — stops everything until done
const data = fs.readFileSync("./big.json", "utf8");
console.log(data);

// Non-blocking — returns instantly, callback runs later
fs.readFile("./big.json", "utf8", (err, data) => {
  console.log(data);
});
console.log("this runs FIRST");

If we use the sync version in an HTTP handler, every request reading that file waits in line. That’s bad. The async version lets Node serve thousands of requests interleaved.

How libuv pulls this off

libuv (the C library Node uses for non-blocking I/O) uses two strategies depending on the operation:

  1. OS-level async APIs — for network I/O (sockets), libuv uses epoll on Linux, kqueue on macOS/BSD, and IOCP on Windows. The OS itself notifies libuv when a socket is ready. Zero extra threads needed.
  2. Thread pool — for things the OS doesn’t expose as async (file system on most platforms, DNS lookups, crypto, zlib), libuv uses a pool of worker threads. Default 4 threads, configurable via UV_THREADPOOL_SIZE (max 1024).
Where I/O actually happens
OS async (no thread)
TCP / UDP sockets, HTTP, pipes
epoll / kqueue / IOCP
Thread pool (4 by default)
fs, dns.lookup, crypto.pbkdf2, zlib
UV_THREADPOOL_SIZE

The flow of an async call

Take fs.readFile:

  1. JS calls fs.readFile(path, cb).
  2. Node passes the work to libuv.
  3. libuv picks a worker thread, that thread calls read() syscalls.
  4. Meanwhile, our main thread is free — it runs other JS, handles requests, whatever.
  5. The worker thread finishes, hands the result back to libuv.
  6. libuv queues our callback in the poll phase of the event loop.
  7. Event loop reaches poll phase → runs our callback.

Why this makes Node fast (for I/O)

A traditional thread-per-request server (think old Apache) needs ~1MB of stack per thread. 10,000 connections = 10GB of RAM just for stacks. Node holds 10,000 connections on one thread with maybe a few hundred MB of memory. The bottleneck shifts from threads to actual work.

But — and this is important — Node is fast for I/O-bound work. For CPU-bound work (image processing, JSON parsing big payloads, cryptography in a tight loop), Node is no faster than anything else, and worse, the heavy code blocks all other requests.

Tuning the thread pool

If we’re doing heavy crypto or lots of fs work, the default 4 threads can become a bottleneck. Bump it:

UV_THREADPOOL_SIZE=16 node server.js

Don’t set this absurdly high — past your CPU core count it just causes context switching.

Worker threads — for CPU work

For genuinely CPU-heavy code, Node has the worker_threads module. These are real OS threads with their own V8 instance. We send messages between them. Use these for things like image resizing, parsing huge files, or running ML inference.

const { Worker } = require("node:worker_threads");
const w = new Worker("./heavy-task.js");
w.on("message", (result) => console.log(result));
w.postMessage({ payload: "..." });

The cardinal rule

Never block the event loop. No JSON.parse on a 50MB string, no fs.readFileSync in a request handler, no while loop computing primes. If we block the loop, every connection waits.