DEV Community

Cover image for My Pool Has 4 Threads, DNS Gets 2
Mustafa ERBAY
Mustafa ERBAY

Posted on Originally published at mustafaerbay.com.tr

My Pool Has 4 Threads, DNS Gets 2

In Node, the word "asynchronous" does not describe a single thing. Some calls
really are handed to the kernel and come back when the operating system is
ready; others are placed on one of four background threads and wait their turn.
The second kind is libuv's work pool, its size is four, and that number appears
in Node's documentation in a single aside: "its current default value".

What led me to that number was my cover generator. In batch mode
generate-hero.ts drains its queue with four workers — a hard-coded 4 inside
the script — and each worker is one sharp call. In a separate run, in a
separate process, my social posting script names nine external hosts in its
source. Both are "asynchronous", both sit on their own four threads, and I had
no idea how much of those four either one was holding. I wanted to measure this
properly, once. The result was sharper than I expected: dns.lookup can never
take more than two
of those four threads, and neither libuv's documentation
nor Node's mentions that limit.

Who lands in the pool

Node's command-line documentation spells out the list: all fs calls except the
file watchers and the explicitly synchronous ones, asynchronous crypto calls
such as crypto.pbkdf2, scrypt, randomBytes and generateKeyPair, all
zlib calls except the explicitly synchronous ones, and dns.lookup. The
warning that follows is fair: because the pool has a fixed size, if one of these
takes a long time, other "seemingly unrelated" ones get slower.

The sneakiest member of that list is dns.lookup, because you are usually not
the one deciding to call it. In Node's lib/net.js, the connect path says it in
one line: const lookup = options.lookup || dns.lookup;. So net.connect,
http, https and fetch — undici, which sits underneath fetch, uses
net.connect for plain HTTP and tls.connect for TLS, and both pass through
that same line — resolve names through dns.lookup unless you supply your own
resolver. The first step of every outbound HTTP request queues on the same four
threads as your file reads.

The dns.resolve* family is not on that list. Node's documentation draws the
line clearly: those functions do not use getaddrinfo(3), they always query
over the network, and they do not use libuv's threadpool. Keep that distinction
in mind; we will come back to it with a measurement at the end.

The lab: my own resolver, a fixed 300 ms

Measuring against a real DNS server is pointless; if the latency moves, you
cannot read the table. So I wrote a 49-line UDP server: it holds every incoming
query for exactly 300 ms, then answers 127.0.0.2, sets TTL zero (no caching),
and most importantly it counts how many distinct names are in flight at the
same time
. Instead of guessing concurrency from the client side, I let the
resolver count it.

The lab ran in a node:26-alpine container: Alpine 3.24, Node v26.13.0, libuv
1.52.1, aarch64, 14 cores. /etc/resolv.conf has one line: nameserver
127.0.0.1
. The client calls dns.lookup for 24 distinct names at once, with
family: 4, so each name sends a single A query and the resolver's A/AAAA
behaviour stays out of the table. The numbers below are medians of three runs.
Alpine uses musl; since the limit measured here lives in libuv, the resolver
library does not change the result, but the nsswitch.conf argument later in
this article applies to glibc setups.

UV_THREADPOOL_SIZE Concurrent queries counted by the resolver 24 names, total
unset (4) 2 3,695 ms
1 1 7,414 ms
2 1 7,427 ms
3 2 3,703 ms
4 2 3,712 ms
6 3 2,480 ms
8 4 1,859 ms
16 8 934 ms

The second column is exactly half the pool size, rounded up.

What amuses me most in this table is the relationship between the second and
third rows. Going from one thread to two moves the total for 24 names from 7,414
ms to 7,427 ms — that is, nowhere. I doubled the threads and what I gained on
the DNS side is smaller than the measurement noise. But going from two to three
cuts the time in half. Adding one thread buys nothing once and a factor of two
the next time.

The reason sits in libuv's source, on line 46 of src/threadpool.c:

static unsigned int slow_work_thread_threshold(void) {
  return (nthreads + 1) / 2;
}
Enter fullscreen mode Exit fullscreen mode

libuv sorts work into three kinds: UV__WORK_CPU, UV__WORK_FAST_IO and
UV__WORK_SLOW_IO. src/unix/getaddrinfo.c submits its request as
UV__WORK_SLOW_IO; src/unix/fs.c submits every file operation as
UV__WORK_FAST_IO, and user code calling uv_queue_work submits as
UV__WORK_CPU. Slow-I/O work piles up in a separate queue and only a single
"there is slow work" marker goes into the main queue. When a thread picks up
that marker and starts running slow work, a counter goes up; if the counter has
reached the threshold, the thread pushes the marker back to the tail of the
queue and looks for other work.

Diagram

So this is not an accident but a deliberate guard: it exists so a slow name
lookup cannot swallow the whole pool and block file reads. I have no objection
to the logic. My objection is that the limit is written down nowhere. libuv's
own documentation covers the default, the absolute maximum, and that the pool is
global and shared across all loops; for its scope it says "all file system
operations, as well as getaddrinfo and getnameinfo requests". Not one sentence
about the half-pool threshold. Node's UV_THREADPOOL_SIZE section does not have
it either: there the advice is simply to set a value greater than 4.

Follow that advice and DNS can use at most half of whatever you set. Write 8 and
the ceiling on your name-resolution concurrency is 4. It is a ceiling, not an
allocation: when there is no DNS work those slots do not sit idle, they take
other work.

Who starves whom

The direction of the threshold matters, because it is asymmetric. Two scenarios,
both with the pool at its default four threads:

A) With 32 pending dns.lookup calls (each answered by my 300 ms resolver)
I squeezed in one fs.readFile and one pbkdf2. The fs.readFile waited 0 ms,
the pbkdf2 2 ms. Thirty-two names are queued and the file read goes straight
through. The guard works.

B) With 32 pending pbkdf2 calls — a computation that takes 208 ms on its
own — I squeezed in one dns.lookup and one fs.readFile. The fs.readFile
waited 1,719 ms, the dns.lookup 2,018 ms.

The arithmetic of that second line lands exactly: 32 × 208 ms on four threads is
a 1,664 ms queue, and the measurement is 1,719 ms. DNS's 2,018 ms comes from the
same place — 1,719 ms of queue plus my resolver's fixed 300 ms, totalling 2,019
ms. The resolver answers in 300 ms while dns.lookup takes two seconds, and 85
percent of that has nothing to do with resolving a name.

The lesson here runs the other way: some investigations that start with "our DNS
is slow" are looking in the wrong place. What fills the pool is not name
resolution but the crypto, compression or image work in the same process. The
slow-I/O threshold stops DNS from choking everyone else; nothing stops everyone
else from choking DNS.

Where the threshold sits on my own pipeline

The same question goes to my cover generator, since that is where it started.
came from. generate-hero.ts makes a single sharp call: it rasterises the SVG
at 200 dpi, fits it to 1200×630 and writes a PNG with compressionLevel: 9. On
my Mac (Node v22.16.0, libuv 1.49.2, sharp 0.35.5, libvips 8.18.7, 14 cores)
that call takes 75 ms on its own. On an idle pool an fs.readFile takes 0.2 ms.

I started the same call N times concurrently and squeezed in a read of a real
article file:

Concurrent covers How long fs.readFile waited
1 0 ms
2 0 ms
3 0 ms
4 9 ms (three runs: 1 – 31 ms)
8 62 ms (three runs: 56 – 69 ms)

The threshold is exactly at four. Up to three there is still a free thread in
the pool, so the read never waits; the moment a fourth cover enters, it starts
waiting. I will not lean on the 9 ms in the fourth row — across three runs it
wandered between 1 and 31 ms, because the instant a thread frees up is a matter
of chance. At eight the number is steady: about 60 ms.

Something caught my eye here. My script does not only write two PNGs per article
and exit; in --all mode it drains the queue with four workers, and that four
is a constant I put there myself: Array.from({ length: 4 }, ...). The comment
next to it says it "gains parallel I/O". So when I rebuild the covers for all
2,772 posts I fill the entire pool, and the fourth row of the table is exactly
that point. When I wrote that 4 I did not know the pool was also four; the two
numbers matching is a coincidence, what follows from it is not — during an
--all run every fs read and every dns.lookup in the same process queues
behind it.

The absolute values are small, I know. What interests me is not the absolute
value but that the threshold is visible at all. And one more thing: there is not
a single line in my repository that mentions UV_THREADPOOL_SIZE. Not in the
scripts, not in the systemd units, not in the workflows. I have been running
on four threads and until today I had not chosen that deliberately.

When exactly is the pool born

Node's documentation says that setting process.env.UV_THREADPOOL_SIZE "is not
guaranteed to work" and gives a reason: the pool would have been created "as
part of the runtime initialisation much before user code is run". The warning
itself is right; the reason does not hold on the Node in this lab.

libuv's pool is lazy. The first line of uv__work_submit is uv_once(&once,
init_once)
— the pool is born when the first piece of work is submitted. We can
count it from outside, because since libuv 1.50.0 the threads are named
libuv-worker:

# process is up but nothing has hit the pool yet
$ ls /proc/<pid>/task/*/comm | xargs cat | grep -c libuv-worker
0
Enter fullscreen mode Exit fullscreen mode

After a single dns.lookup, that same count returns whatever
UV_THREADPOOL_SIZE says — 4 if you wrote 4, 128 if you wrote 128, for one
request. That is precisely libuv's sentence about preallocating the maximum
number of threads allowed. Five rows, same lab, on where the value is set:

Where it was set Concurrency counted by the resolver 24 names
nowhere (default 4) 2 3,695 ms
first line of the JS: process.env... = '16' 8 930 ms
the same line after one fs.readFile 2 3,720 ms
UV_THREADPOOL_SIZE=16 in the shell 8 923 ms
node --env-file=uv.env 8 934 ms

The third row vindicates the documentation's warning, but for a different
reason: the setting is not missed because the runtime built the pool early; it
is missed because someone submitted work to the pool before you did. A library
that reads a file with top-level await, or a dependency that calls
randomBytes, quietly makes your setting irrelevant. So the advice stands: do
not set this value from inside your code. The two honest paths in the table
above are the shell environment and --env-file; both land before the first
job.

The bill for growing it

The ceiling is 1024 (libuv 1.30.0 raised it from 128) and each thread wants an 8
MiB stack (since 1.45.0; before that it was the platform default). Readings from
the container's /proc/<pid>/status after a single dns.lookup:

UV_THREADPOOL_SIZE libuv-worker VmSize VmRSS Total threads
unset (4) 4 848 MiB 61 MiB 11
8 8 880 MiB 61 MiB 15
128 128 1,841 MiB 61 MiB 135
1024 1024 9,021 MiB 75 MiB 1,031

Going from four to eight adds 32 MiB to VmSize: four threads × 8 MiB, exact.
At 1024 the virtual size reaches 9 GiB while resident memory stays at 75 MiB —
the stacks are paged lazily, just as libuv's documentation says. So the thing to
worry about is not RSS; it is virtual address space in an environment capped by
ulimit -v, and a scheduler that now has 1,031 threads to deal with.

My decision: for a process like my blog pipeline, which writes a couple of
images per article and talks to five platforms, 16 is more than enough. Values
in the hundreds only make sense in a process that genuinely makes hundreds of
concurrent slow calls, and even there the real question is not the pool size but
why those calls are concurrent.

How to leave the limit behind entirely

The cleanest exit is not to grow the pool but not to use it. dns.resolve4 runs
on c-ares; the query happens on the event loop, not in the pool. I asked the
same 24 names of the same 300 ms resolver, with the default four-thread
pool
:

Method Concurrent 24 names
dns.lookup 2 3,695 ms
dns.resolve4 24 312 ms

All twenty-four in flight, and the total is the duration of one query. More than
an eleven-fold difference, and I never touched the pool.

It is not free, of course. Node's documentation states the difference honestly:
dns.resolve* does not read /etc/hosts, and it does not use nsswitch.conf
and resolv.conf the way dns.lookup does. So there is no guarantee it gives
the same answer as ping. In a container, service names written into
/etc/hosts, mDNS, search domains — all of that disappears on this path. The
middle ground the documentation points to is more sensible: resolve the name
with dns.resolve() and use the address, or replace the lookup option that
APIs such as socket.connect() and dgram.createSocket() accept with your own
implementation — which is also exactly where a cache belongs.

Questions to ask about your own setup

  • How many distinct hosts does your process talk to? Each one means a default dns.lookup, and they all share at most two threads.
  • Does the same process do image processing, zlib, pbkdf2 or heavy file reads? If so, what fills the pool is probably not DNS but those.
  • Is UV_THREADPOOL_SIZE set from inside the code? If you cannot prove no work hit the pool before that line, the setting may be ignored.
  • Do you know how many threads your pool opened with? On Linux, counting libuv-worker in /proc/<pid>/task/*/comm takes two seconds (libuv 1.50.0 and above); on macOS you need sample or vmmap.
  • If you have a slow-resolver problem, should the answer be dns.resolve? Yes if you have no dependency on /etc/hosts and nsswitch; otherwise your own lookup.
  • If your traffic goes through global fetch, how do you hand it a lookup? fetch takes no such option; the path runs through undici's dispatcher — setGlobalDispatcher(new Agent({ connect: { lookup } })).
  • You opened eight worker_threads and assume you have eight pools? The thread count and the queues in threadpool.c are static: there is one pool per process. Eight workers share the same four threads and the same two DNS slots.
  • What happens when your nameserver dies? A getaddrinfo that has already started cannot be cancelled — uv__work_cancel can only pull work that has not begun off the queue — so two threads stay pinned for as long as timeout × attempts in resolv.conf says.

Limits without counters

Not one measurement in this article produces an alert. There is no counter that
goes up when the pool fills. The tooling is not entirely absent: perf_hooks
has a 'dns' entry type that tells you how long dns.lookup took, and the gap
between the node.threadpoolwork.async and .sync traces gives you the time
spent queueing. But the first one does not tell you why it took that long, and
the second is never emitted by the pool's two busiest customers — dns.lookup
and fs — because neither goes through the code that emits it. The limit is
there; in practice the only evidence is in your p99.

I ran into the same shape in the CPU quota
article
: a process using
seventeen percent of its quota, throttled half a million times in ten days,
invisible to every dashboard that looks at averages. In the keepalive
article
the
setting was right but almost no application read it. Here is a third variant:
the setting is documented, it is recommended, and half of the recommendation is
lost on the way — and the place it is lost is written on line 46 of the source.

Two threads out of four. If you did not choose that deliberately, libuv chose it
for you.

Official Sources

Top comments (0)