Mission 06 · Node.js Internals Campaign

The thread pool — four subsystems, one queue, and four threads by default

Part 5 introduced the pool as the thing that makes fs.readFile asynchronous, and every part since has called it "the pool". That is true and incomplete: four unrelated subsystems share it — the filesystem, DNS name resolution, several crypto functions and all of zlib — and by default they share four threads and one first-in-first-out queue.

8 sections7 figuresfour membersfs · dns · crypto · zlib
§ 0 · THE SHORT ANSWER FIRST

Four unrelated subsystems, four threads, one queue

Part 5 introduced the thread pool as the thing that makes fs.readFile asynchronous, and every part since has referred to it as "the pool". That framing is true and incomplete, and the incompleteness is where the production surprises come from.

Four different subsystems share it — the filesystem, DNS name resolution, several crypto functions and all of zlib — and by default they share four threads. They have nothing to do with each other, they queue together, and none of them knows the others exist.

JAVASCRIPTTHE COLLISION NOBODY EXPECTS
zlib.gzip(bigBuffer, () => {});     // ×4, one per pool thread
zlib.gzip(bigBuffer, () => {});
zlib.gzip(bigBuffer, () => {});
zlib.gzip(bigBuffer, () => {});

dns.lookup("api.example.com", () => {});   // now waits for a compression job

Nothing there is misconfigured. The DNS lookup is queued behind four compression jobs because compression and name resolution are served by the same four threads, and the queue is first-in, first-out.

!Lock this in

The pool is not "the filesystem pool". It is a general work queue for blocking C code, and every subsystem that uses it competes with every other one for the same four threads.

§ 1 · WHY THESE FOUR AND NOTHING ELSE

No readiness interface means no other option

Part 0 §8 and Part 8 established the rule that decides this. A socket can be watched: you ask the kernel to tell you when it is ready, and one thread can hold ten thousand of them. A regular file cannot — it is always ready, so the answer is useless and the only way to find out how long a read takes is to make it and see.

What sends work to the pool
§1 · the test
does it have a readiness interface?
can epoll watch it?
kernel
If yes, the loop watches it and no thread is needed — that is every socket in Parts 8, 9 and 11. If no, someone has to sit and wait, and it cannot be the main thread.
is the work pure CPU in C?
compression, key derivation
node
Then there is nothing to watch at all. It is not waiting, it is computing — and a thread that computes for 200ms is exactly as unavailable as one that waits for 200ms.
is it your JavaScript?
then the pool cannot take it
worker
Pool threads have no V8 isolate, as Part 10 spends a figure on. A CPU-heavy loop of yours needs a worker_thread, not this.
blocking C with no readiness interface — that is the whole membership test

Which is why the membership list looks arbitrary until you apply the test, and then looks inevitable.

§ 2 · THE WHOLE MEMBERSHIP LIST

fs, dns.lookup, some crypto, all zlib

Here is everything in Node that hands work to the pool, and the shape of what it submits.

What each one submits
§2 · the members
fs — every asynchronous operation
read, write, stat, readdir, open, close
node
Part 5's subject. Each call is one job. A readFile of a large file is one job that holds a thread for the whole read, which is why a directory walk of thousands of files saturates the pool so easily.
dns.lookup
getaddrinfo — the OS resolver
kernel
The one that surprises people, and §3 is entirely about it. It is a BLOCKING C call into the system resolver, so it needs a thread — even though what it is doing is network work.
crypto — the async forms only
pbkdf2, scrypt, randomBytes, randomFill
node
Pure CPU, deliberately handed to the pool so it does not block the loop. The Sync variants of all of these run on the main thread instead, which is the trap.
zlib — everything
gzip, deflate, brotli, and their streams
node
All compression is pool work. A streaming gzip submits a job per chunk, so one large response can occupy a thread repeatedly rather than once.
and nothing else
not net, not http, not tls, not timers
main
Sockets are watched, not waited on. Timers are a number passed to epoll_wait. Your JavaScript runs on the main thread. None of these touch the pool.
four members · one queue · four threads by default
!Lock this in

If you have ever wondered why a Node service gets slower during a burst of gzip, and the slowdown shows up as DNS timeouts, this list is the reason. They are the same queue.

§ 3 · DNS.LOOKUP VS DNS.RESOLVE

The same answer by two completely different routes

These two functions look like variants of one thing. They are not: one takes a pool thread and one does not touch the pool at all.

Which one uses a thread?
§3 · two routes to a name
the control is the one that quietly consumes your pool under load

Almost everything resolves names through dns.lookup without asking for it: http.request, fetch, database drivers, every client library that takes a hostname. So a service making many outbound connections is submitting pool jobs constantly, and it never appears in the code as a pool operation.

!Lock this in

dns.lookup consults the operating system — hosts file, nsswitch, everything — and that API is blocking, so it needs a thread. dns.resolve sends a DNS query over a socket, which the loop can watch like any other socket. Same question, opposite mechanism.

§ 4 · FOUR THREADS, ONE QUEUE

A compression job can delay a name lookup

Part 5 showed the pool saturating with file reads. That figure held one subsystem constant; this one is about what happens when the jobs come from different subsystems, because that is the case nobody plans for.

Mixed work, four threads
§4 · the shared queue
the control raises the pool size — and shows what that does and does not fix

The queue is first-in, first-out and jobs are not preemptible. A pbkdf2 at high iteration counts holds a thread for as long as it takes, and everything behind it waits — including a DNS lookup that would have taken a millisecond.

!Lock this in

The symptom almost never names the cause. You see slow DNS, or slow file reads, and the actual culprit is a different subsystem entirely that happens to share the queue.

§ 5 · THE ONE KNOB, AND ITS RULES

Read once, at first use, and never again

The pool size is set by an environment variable, defaults to 4, and has two properties that make it easy to set ineffectively.

JAVASCRIPTTOO LATE — THE POOL IS ALREADY SIZED
const fs = require("fs");
fs.readFile(__filename, () => {});        // pool created HERE, with 4 threads

process.env.UV_THREADPOOL_SIZE = 16;      // no effect, ever
What the knob actually does
§5 · the rules
it is read ONCE, at first pool use
not per call, not per module
node
The pool is created lazily, and its size is fixed the moment the first job is submitted. Setting the variable after that changes nothing, and nothing warns you.
so set it before anything uses it
the environment, or the very first line
main
In practice that means the shell, a process manager, or the top of your entry file before any require that might touch fs.
the maximum is 1024
and that is not the useful number
kernel
These are real OS threads. Past your core count they compete for the same cores — Part 11's cost curve, with the same shape and the same conclusion.
it does not help I/O-bound work
because that work was never here
worker
More threads only help if jobs are actually queueing. Sockets never queue here at all, so a socket-heavy server sees nothing from raising it.
one number, fixed at first use, capped at 1024
§ 6 · THE MODELS WORTH CORRECTING

Ten claims about the pool

These are the beliefs that make pool exhaustion so hard to diagnose — mostly because they scope the pool too narrowly.

Which of these are true?
§6 · mental models
sort before you check — a guess you have committed to is worth more than a correction you read
!Lock this in

Nearly every wrong take here assumes the pool belongs to one subsystem. It belongs to four, they share one queue, and the one that saturates it is rarely the one that looks slow.

§ 7 · THE FOUR SENTENCES WORTH KEEPING

If you remember nothing else

The pool is a general work queue for blocking C code, not a filesystem feature.

What to carry away
§7 · the four sentences
four subsystems, one queue, four threads
§0 · §2
node
fs, dns.lookup, the async crypto functions and all of zlib. They compete, and the symptom rarely names the cause.
dns.lookup uses a thread; dns.resolve does not
§3
kernel
And almost every client library uses lookup without saying so, which is how a service submits pool jobs it never wrote.
the size is fixed at first use
§5
main
Set UV_THREADPOOL_SIZE in the environment, not after a require has already warmed the pool.
sockets were never here
§1
worker
They have a readiness interface, so the loop watches them. Raising the pool size does nothing for a socket-bound server.
four sentences, one queue

That completes the picture Part 5 started. Part 5 followed one call into the pool and back; this part is everything else that was already in there with it.

Back to JIT-Atlas