Skip to content

The libuv thread pool: fs, dns.lookup, crypto, zlib

The pool is a short crew behind one long queue, and a host name is a ticket in that queue.

Predict first

Before you read: predict

ExamHall's login API runs with Node's default thread pool. At 09:55, 44 candidates log in at the same moment, and each login runs one async crypto.scrypt password check. Right after those 44 checks are started, the load balancer's /health request arrives, and its handler pings a local sidecar with http.get('http://localhost:9100/ping') on a new connection.

When does the sidecar receive the ping?

Pick one

The 30-second version

Node's pool is a few threads behind one first-in, first-out queue. A pool call waits for every task queued before it, and a stuck task keeps its thread. dns.lookup runs on the pool; dns.resolve4 asks a DNS server from the event loop. Every new connection to a host name calls dns.lookup first, so it queues too.

The pool is a short crew behind one long queue, and a host name is a ticket in that queue.

How it works

Step 1 of 11

Linked. Which calls use the pool, its default of 4 threads and when its size is set are node-scaling-workers-cluster's steps 10–12.
In the kitchen
  • the clerk, who takes every order and handles the phone and the post herself → the main thread; sockets never go to the yard (step 1)

Analogy: A monumental mason's workshop

One clerk at the counter, four masons at four benches in the yard, and one order spike.

In the workshop In Node
the clerk, who takes every order and handles the phone and the post herself the main thread; sockets never go to the yard (step 1)
four masons, a number the owner fixed before opening the pool's threads
one order spike: slips go on top in arrival order, and a mason who comes free takes the oldest one FIFO queue (steps 2–3)
a mason waiting at his bench for a stone delivery that has not come: the bench stays taken a stuck read holding its thread (steps 4–5)
finding a customer's address in the yard's card index is written on a slip and spiked like any job dns.lookup on the pool (step 6)
ringing directory enquiries from the counter phone dns.resolve4 from the event loop (step 7)
a letter addressed by name needs an index slip first; a letter with the address already written goes straight out a host name against an IP address (steps 8–9)
the clerk spikes at most two headstone orders at once and keeps the rest in her book bounding pool-heavy work (step 11)

In real terms: when a Node service slows down with CPU to spare, list what is queued for the pool and in what order, including the lookups your connections make.

Breaks down when: a sensible foreman lets a two-minute index slip jump ahead of a day's carving; libuv never reorders the queue.

Contrasting case: a Java server with a thread per request gives each request its own thread to block on, so a slow lookup delays only that request; Node shares a few pool threads across every request.

Examples, run for real

4.1 ExamHall: a paper that waited for everyone's password

EXH-3690: at 09:55, a wave of candidates logged in at once. Each login started an async crypto.scrypt check as soon as it arrived, and each candidate's exam paper was then read from disk with fs.readFile. Papers opened late, although the CPU was far from full. MR !3990 keeps at most two checks submitted to the pool at a time; the rest wait in the login handler's own queue.

4.1 09:55, 36 logins at once, default pool (4 threads), then one paper read (fs.readFile)
  as shipped: 36 scrypt checks submitted together      paper read called back after at least 33 checks: true · checks done 36
  MR !3990: at most 2 checks submitted at a time        paper read called back before the 5th check: true · checks done 36 · most in flight 2

As shipped, the read was a pool task behind thirty-six hashes: four ran and thirty-two waited, so the read could start only once thirty-three had finished (steps 2–3). With the MR, two threads are always free, so the read starts at once (step 11). Every login still completes; the waiting just moves into the handler, where the paper read is not behind it.

4.2 What the MR does not change

The pool still has its default four threads, and nothing about the hashes got cheaper. The MR changes only what is in the queue when the read arrives. The handler's own queue now holds the wave, and libuv's queue holds at most two checks, so a read waits for nothing in front of it (steps 3, 11).

Picture it

Concept illustration for this lesson.
An illustration to help it stick. The mechanism is in the sections above.

Try it

Answer first, then check. Nothing is saved.

Question 1 of 6

A ticketing service runs with Node's default thread pool. Four reads of seat maps hang on a network mount. While they hang, the checkout code resolves the payment gateway with resolver.resolve4(PAY_HOST, cb) on a dns.Resolver set to the cluster's DNS server, the seat-map module calls dns.lookup(PAY_HOST, cb), and the receipt module calls fs.stat on a template on the local disk.

Which of the three complete before the mount recovers?

Pick one

Question 2 of 6

At an on-sale, 44 fans log in at once; each login runs one async crypto.scrypt check on Node's default thread pool. Right after, checkout calls the payment gateway through the pod's egress sidecar with http.get('http://localhost:15001/charge') on a new connection. The team wants that call to reach the sidecar before 10 of the 44 checks have called back.

Which one change does it?

Pick one

Question 3 of 6

A ticketing service sets ENV UV_THREADPOOL_SIZE=5 in its Dockerfile. Four reads of seat maps hang on a network mount. While they hang, the service starts 30 async crypto.pbkdf2 checks of partner API keys, and then one dns.lookup for the payment gateway.

When the lookup calls back, how many of the 30 checks have called back?

Pick one

Question 4 of 6

A checkout service runs with Node's default thread pool and calls its payment sidecar at http://localhost:15001. Before the trouble starts, one call goes through a keep-alive agent (maxSockets: 1), which keeps that connection open. Then four reads of seat maps hang on a network mount. While they hang, checkout sends 3 calls through the keep-alive agent and 3 with agent: false.

How many of the 6 calls are answered before the mount recovers?

Pick one

Question 5 of 6

A payments ledger runs with Node's default thread pool. Each payout job posts to the bank's API with http.get(`http://${BANK_HOST}/payouts`, { agent: false }). This morning, four statement reads hung on a network mount right after the ledger started, before its first payout, and no payout reached the bank until the mount recovered.

Which one change lets payouts through while the reads hang?

Pick one

Question 6 of 6

A chat server runs with Node's default thread pool. Four reads of user avatars hang on a network mount. While they hang, it resolves its relay's host name chat-relay.internal with resolver.resolve4 on a dns.Resolver set to the cluster's DNS server. When that answers, it sends one message to http://chat-relay.internal:7600 and then one to the address resolve4 returned, each on a new connection.

Which complete before the mount recovers?

Pick one

Go deeper

Pro tips
  1. Cap pool-heavy work in your own code. Put async hashing (scrypt, pbkdf2) and compression behind a small limiter, so a burst waits in your queue and the pool keeps free threads for everything else. When: any endpoint that hashes or compresses per request, before a known spike such as an exam start (step 11).
  2. Test pool saturation with a barrier, not a sleep. In a test, hold pool threads with reads of a named pipe (mkfifo) and release them by opening the pipe's write end, then assert which calls finished while they were held. When: writing a regression test for a pool-starvation incident (step 4). Verify (RAN, lib/pool.mjs): every graded script here holds threads this way; each IMPURE script gave 1 distinct output over its repeated runs.
Source
  • Node.js v24 API, DNS, "Implementation considerations" (source doc/api/dns.md, branch v24.x): https://nodejs.org/docs/latest-v24.x/api/dns.html#implementation-considerations · fetched 2026-10-12 (sources/dns.raw). dns.lookup() "is implemented as a synchronous call to getaddrinfo(3) that runs on libuv's threadpool"; it "will almost always resolve a given name the same way as the ping command" (step 6). "Various networking APIs will call dns.lookup() internally to resolve host names. If that is an issue, consider resolving the host name to an address using dns.resolve() and using the address instead of a host name". The resolve* functions "do not use getaddrinfo(3) and they always perform a DNS query on the network. This network communication is always done asynchronously and does not use libuv's threadpool"; "they do not use the configuration from /etc/hosts" (step 7, card 6). Valid as of 2026-10-12.
  • Node.js v24 API, net, socket.connect(options) and HTTP, http.request(options) (sources doc/api/net.md, doc/api/http.md, branch v24.x): https://nodejs.org/docs/latest-v24.x/api/net.html#socketconnectoptions-connectlistener · https://nodejs.org/docs/latest-v24.x/api/http.html#httprequestoptions-callback · fetched 2026-10-12 (sources/net.raw, sources/http.raw). Both list "lookup {Function} Custom lookup function. Default: dns.lookup()". Valid as of 2026-10-12.
  • Node.js v24 API, Command-line options, UV_THREADPOOL_SIZE=size (source doc/api/cli.md, branch v24.x): https://nodejs.org/docs/latest-v24.x/api/cli.html#uv_threadpool_sizesize · fetched 2026-10-12 (sources/cli.raw). The list of APIs that use the pool and "Because libuv's threadpool has a fixed size, it means that if for whatever reason any of these APIs takes a long time, other (seemingly unrelated) APIs that run in libuv's threadpool will experience degraded performance". Valid as of 2026-10-12.
  • libuv, Thread pool work scheduling (source docs/src/threadpool.rst, v1.x): https://docs.libuv.org/en/v1.x/threadpool.html · fetched 2026-10-12 (sources/libuv-threadpool.raw). "This thread pool is internally used to run all file system operations, as well as getaddrinfo and getnameinfo requests" (step 6); "The threadpool is global and shared across all event loops"; "More threads usually means more throughput but a higher memory footprint" (step 10). Valid as of 2026-10-12.
  • Node.js Learn, Don't Block the Event Loop (or the Worker Pool): https://nodejs.org/en/learn/asynchronous-work/dont-block-the-event-loop · fetched 2026-10-12 (sources/dontblock.raw). "the Worker Pool uses a real queue whose entries are tasks to be processed. A Worker pops a task from this queue and works on it" (step 2); "If you have 4 logical cores and 5 Workers, one of these Workers cannot make progress. As a result, you are paying overhead (memory and scheduling costs) for this Worker and getting no return for it" (step 10, card 8); "each relatively long Task effectively decreases the size of the Worker Pool by one until it is completed" (steps 4–5). Valid as of 2026-10-12.
  • libuv source, src/threadpool.c (v1.x): https://github.com/libuv/libuv/blob/v1.x/src/threadpool.c · fetched 2026-10-12 (sources/libuv-threadpool-c.raw). Slow I/O requests (getaddrinfo) are additionally capped at (nthreads + 1) / 2 threads; no item here depends on that cap.

Checked against the tools and docs as of 2026-10-12.