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:
- OS-level async APIs — for network I/O (sockets), libuv uses
epollon Linux,kqueueon macOS/BSD, and IOCP on Windows. The OS itself notifies libuv when a socket is ready. Zero extra threads needed. - 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).
The flow of an async call
Take fs.readFile:
- JS calls
fs.readFile(path, cb). - Node passes the work to libuv.
- libuv picks a worker thread, that thread calls
read()syscalls. - Meanwhile, our main thread is free — it runs other JS, handles requests, whatever.
- The worker thread finishes, hands the result back to libuv.
- libuv queues our callback in the poll phase of the event loop.
- 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.