#Call stack, libuv and the thread pool
Four pieces work together every time your code does something asynchronous:
- The call stack (V8) — where JavaScript actually executes, one frame per active function call. There is one stack for your code.
- Node's native layer and libuv — when you call an async API, Node's C++ bindings hand the work to libuv, which either asks the operating system to watch a socket or gives the job to its thread pool.
- The queues — when the work finishes, libuv records that a callback is ready. There isn't one queue: each kind of callback waits for its own phase, and Node adds two high-priority queues of its own (nextTick and promise microtasks).
- The event loop — a loop in libuv's C code that, whenever the stack is empty, picks up ready callbacks and calls them, one at a time, back on the main thread.
The mental model from chapter 1 still holds: the chef (main thread) never waits. The kitchen manager (libuv) does the waiting, sometimes using a few helpers (the thread pool), and passes the chef a ticket (the callback) when the result is ready. The JavaScript site covers the browser version of this picture in The event loop; this chapter is the Node-specific detail.
Here is who does what, step by step, for one file read:
const fs = require('node:fs');
function getStuff() {
fs.readFile(__filename, (err, data) => {
if (err) return console.error(err);
console.log('DONE', data.length);
});
console.log('AFTER READ');
}
getStuff();
console.log('getStuff returned');
// AFTER READ
// getStuff returned
// DONE 307- The script calls
getStuff(), which is pushed onto the call stack. Stack:[main, getStuff]. getStuffcallsfs.readFile, which is pushed too. Node's JavaScript code validates the arguments, creates a native request through the C++ bindings, and libuv queues the actual open/read/close work on its thread pool. Thenfs.readFilereturnsundefinedand is popped. The callback is not run; it is stored on the request object.getStuffis still on the stack and carries on:AFTER READ. Then it returns normally and is popped, thengetStuff returnedlogs and the script ends. Stack: empty.- Meanwhile a pool thread has been reading the file. When it finishes, it tells the loop; the loop, now free because the stack is empty, calls the stored callback on the main thread with
err = nulland a 307-byteBuffer(this file, comments included):DONE 307. - Note what did not happen:
getStuffwas never "popped off and delegated" when it made the async call. Only the callback was set aside; everything else ran to completion first.
Which work goes where:
| Operation | Who does the waiting / work | Thread pool? |
|---|---|---|
| TCP/UDP sockets, HTTP servers and clients, pipes | The OS kernel; libuv is notified through epoll (Linux), kqueue (macOS), IOCP (Windows) | No — one thread can watch thousands of sockets |
Most async fs.* calls (readFile, stat, open…) | A libuv pool thread making a blocking system call | Yes |
crypto.pbkdf2, crypto.scrypt, crypto.randomBytes (async), async zlib | A libuv pool thread doing CPU work in C++ | Yes |
dns.lookup (used by http.get('example.com') by default) | A pool thread calling the OS resolver (getaddrinfo) | Yes |
dns.resolve* | c-ares over the network | No |
setTimeout, setInterval, setImmediate | Nobody — they are entries in data structures the loop checks | No |
Promises, await, process.nextTick | Nobody — they are queues drained on the main thread | No |
The pool is small: 4 threads by default. That is easy to see by starting more jobs than threads:
const crypto = require('node:crypto');
const start = performance.now();
for (let i = 1; i <= 6; i++) {
crypto.pbkdf2('secret', 'salt', 1_000_000, 64, 'sha512', () => {
console.log(`hash ${i} done at ${Math.round(performance.now() - start)} ms`);
});
}
console.log('all 6 started');
// $ node pool.cjs
// all 6 started
// hash 1 done at 195 ms
// hash 2 done at 199 ms
// hash 4 done at 205 ms
// hash 3 done at 206 ms
// hash 5 done at 383 ms
// hash 6 done at 405 ms
//
// $ UV_THREADPOOL_SIZE=6 node pool.cjs
// all 6 started
// hash 6 done at 201 ms
// hash 5 done at 210 ms
// hash 2 done at 213 ms
// hash 3 done at 217 ms
// hash 1 done at 218 ms
// hash 4 done at 223 ms- The loop calls
crypto.pbkdf2six times. Each call queues a job for the thread pool and returns immediately, soall 6 startedprints first, within a millisecond. - With the default pool of 4, jobs 1–4 start at once on four threads and each takes ~200 ms of CPU. Jobs 5 and 6 sit in libuv's work queue: there's no free thread.
- At ~200 ms the first four finish (in whatever order the threads complete — 4 before 3 here), two threads free up, jobs 5 and 6 start, and they finish at ~400 ms. Two "rounds" of work.
- With
UV_THREADPOOL_SIZE=6, all six run at once and all finish around 200 ms — in a scrambled order, because six threads race. - The callbacks themselves still run one at a time on the main thread. The pool gives you parallel native work, not parallel JavaScript.
UV_THREADPOOL_SIZEUV_THREADPOOL_SIZE is read once, when the pool is first used, and is capped at 1024 (asking for 2000 gave exactly 1024 pool threads on Node 26). Set it in the environment that starts the process — UV_THREADPOOL_SIZE=8 node server.js or in the Dockerfile. Assigning process.env.UV_THREADPOOL_SIZE at the very top of the entry file also works, but only if nothing has touched the pool yet, which is fragile.