The idea: one JS thread, many helpers

Node.js runs all of your JavaScript on one thread. Yet a Node server reads files, hashes passwords and talks to thousands of sockets at the same time. It can, because a call like fs.readFile does not do the work in JavaScript. It goes down through Node's own JavaScript library, a C++ binding and libuv, and the work then runs somewhere else: in the kernel, on a pool thread, or on another thread you started. Only the callback comes back to the JS thread.

Four layers stacked: your JavaScript, Node's lib JavaScript, the C++ bindings in src, and libuv. Under libuv the work goes three ways: network sockets to epoll in the kernel with no thread; fs, dns.lookup, crypto and zlib to the thread pool of 4 threads; sync calls stay on the JS thread. Results come back through uv_async_send or epoll to the poll phase, where the callback runs on the JS thread
A Node call crosses four layers; the work then runs in the kernel, on a pool thread or on the JS thread itself, and only the callback comes back.

The canvas has two bands. The top band shows main.js, the call path of the current call (one box per layer) and the console. The bottom band is a timeline with one lane per thread: the JS thread, libuv's work queue and its four pool threads, a worker thread, and the kernel's sockets. A light bar is work in progress; it turns solid with a ✓ when it is finished. The red line is the current time.

From a JS call to libuv

Every async API has the same shape. Node's JavaScript library (lib/fs.js, lib/net.js, …) checks the arguments and creates a request object. It calls a function of an internal binding, written in C++ (src/node_file.cc, src/tcp_wrap.cc, …). The binding fills in a libuv request and calls libuv. When libuv reports that the request is done, the binding calls the JavaScript callback that was stored on the request. (Demo: one call, end to end.)

CallC++ bindinglibuvRuns on
fs.readFilenode_file.ccuv_fs_open, uv_fs_readthread pool
crypto.pbkdf2, scrypt, randomBytesCryptoJob::Runuv_queue_workthread pool
zlib.gzipnode_zlib.ccuv_queue_workthread pool
dns.lookupcares_wrap.cc GetAddrInfouv_getaddrinfothread pool
dns.resolve4cares_wrap.cc QueryAWrapuv_poll_start on c-ares's socketno thread (epoll)
net, http socketstcp_wrap.ccuv_tcp_connect, uv_read_startno thread (epoll)
crypto.pbkdf2Sync, JSON.parseruns the work directlynot usedthe JS thread

Network I/O: no thread at all

A TCP socket can be made non-blocking, and Linux can tell a process which of its sockets are ready with epoll. So libuv never gives a socket a thread. It registers the fd with epoll_ctl, and the JS thread sleeps in epoll_wait during the loop's poll phase until some fd is ready. While the request is in flight, the kernel's TCP stack holds the data; nothing in Node is running. In Demo: 3 HTTP requests, no threads, three sockets wait from 0 to 30 ms, all four pool threads stay idle, and the callbacks run at 30, 32 and 34 ms.

The demo uses an IP address on purpose. http.get('http://api.example.com/') first resolves the name with dns.lookup, and that goes to the thread pool.

The thread pool

Some work has no non-blocking interface that epoll can watch. A regular file is always "ready" for epoll, but a read() from disk can still block. getaddrinfo() is a blocking libc function. Hashing and compressing are just CPU work. libuv runs all of these on a thread pool: a fixed set of threads that take jobs from one FIFO work queue. Its size is UV_THREADPOOL_SIZE: 4 by default, at most 1024, read once when the pool first starts. The pool is shared by the whole process.

With more jobs than threads, the rest wait. In Demo: 5 × pbkdf2 on 4 pool threads, hashes #1 to #4 run on the four threads from 0 to 40 ms. #5 waits in the queue until a thread is free, runs from 40 to 80 ms and finishes a whole round later.

Timeline from 0 to 80 ms with four pool threads: hashes 1 to 4 run from 0 to 40 ms, one per thread; hash 5 waits in the work queue from 0 to 40 ms, then runs on thread 1 from 40 to 80 ms
Five 40 ms hashes on four threads: the fifth waits in the queue and finishes at 80 ms, not 40.

This is why a slow disk or many crypto.pbkdf2 calls can make an unrelated fs.readFile slow: it waits in the same queue. Raising UV_THREADPOOL_SIZE helps for I/O-bound jobs; for CPU-bound jobs, more threads than CPU cores does not help.

dns.lookup vs dns.resolve

Node has two ways to turn a name into an address, and they run in different places.

dns.lookupdns.resolve4 and the other resolve*
Codelibc getaddrinfo()the c-ares library
Runs ona pool thread (it blocks)no thread: a UDP socket on epoll
Reads /etc/hosts, nsswitchyes, the same answer as pingno, it always asks a DNS server
Used byhttp.get, net.connect with a host nameonly when you call it

In Demo: dns.lookup vs dns.resolve4, four hashes fill the pool. resolve4 needs no thread and answers at 20 ms. lookup waits for a free thread, runs from 40 to 60 ms and answers at 60 ms, even though the DNS part is just as fast.

Left: dns.lookup goes through uv_getaddrinfo to the work queue and a pool thread that calls getaddrinfo and blocks until the answer comes. Right: dns.resolve4 goes to c-ares, which sends a UDP query on a socket watched by epoll; no thread waits
dns.lookup holds a pool thread while it waits; dns.resolve4 waits on a socket in epoll and holds no thread.

How a result gets back

A pool thread can't call your JavaScript: only the JS thread may touch V8. So when a job is done, the pool thread puts it on a "done" list and calls uv_async_send(), which writes to an eventfd that epoll watches. epoll_wait returns on the JS thread, and in the poll phase libuv calls each done request's after-work function. The binding then calls your callback. A socket takes the same road without the eventfd: epoll reports the fd as readable, libuv reads it, and the binding calls the 'data' handler.

The page shows the loop only as the state of the JS thread lane (running main(), idle: epoll_wait, poll: callback #1). The six phases, process.nextTick and promises, and setTimeout vs setImmediate are on The Node.js Event Loop.

Blocking the JS thread

Anything synchronous runs on the JS thread and holds it: crypto.pbkdf2Sync, fs.readFileSync, zlib.gzipSync, a regular expression that backtracks, or JSON.parse of a 100 MB string, which is V8 work with no Node code involved at all. While it runs, the loop can't run. In Demo: blocking the JS thread, the file is read at 10 ms and the HTTP response arrives at 30 ms, but pbkdf2Sync holds the thread until 40 ms. Both callbacks run only at 42 and 44 ms. The kernel and the pool did their part on time; the JS thread was busy.

worker_threads: a second JS thread

When the work really is JavaScript, or a sync API, move it off the main thread. new Worker('./hash.js') starts a new OS thread with its own V8 isolate and its own libuv event loop. The two threads share no JavaScript objects; they talk with postMessage, which wakes the other thread's loop with uv_async_send. In Demo: move the hash to a worker thread, the same 40 ms hash runs on the worker, and the main thread handles the file at 10 ms and the response at 30 ms. A worker's async fs and crypto calls still use the one process-wide thread pool.

WorkWhere to put it
Waiting for the networkjust use the async API: it costs no thread
Files, DNS lookups, hashing, compressionthe async API (thread pool); size the pool if many run at once
Long JavaScript or sync-only librariesa worker thread, or split it into chunks with setImmediate

What the page leaves out

fs.readFile is really several pool requests in a row (open, fstat, read, close), not one. libuv on macOS uses kqueue and on Windows IOCP, where even files can be async. libuv 1.45 can use io_uring for some file operations on Linux, which Node later turned off by default (see io_uring vs epoll). libuv also marks getaddrinfo as "slow I/O" and lets at most half of the pool threads run such jobs at once, so DNS can't take the whole pool. The page leaves out the TCP handshake, the HTTP parser, streams and backpressure, timers and microtasks, and how long a worker takes to start. JavaScript costs 2 ms per callback and 0 ms per statement, except for sync calls.

See also The Node.js Event Loop for the loop phases, Linux epoll for what happens inside epoll_wait, and Java's ThreadPoolExecutor for a thread pool that can grow.