1.2 (bonus) · DNS resolution: the call that borrows a chef
dns.lookup() vs dns.resolve(), why DNS competes for the libuv thread pool, and how a Kubernetes fan-out turns a hostname into a latency problem.
- Essay
- Quiz · 4
On this page
Before your code can open a connection to payments-service, something has to turn
that name into an IP address. In Node, that small step can quietly occupy one of only four
threads. This is a bonus chapter to Module 1.2 of a series
on Node.js performance: the DNS call that post promised to unpack.
The event loop post ended with a picture of two kitchens. Kitchen one is the order board: network sockets, handed to the OS, with no thread waiting on any of them. Kitchen two has four chefs: the libuv thread pool, where blocking work goes to wait. DNS is a good example of why the difference matters, because Node gives you two ways to resolve a hostname, and they go to different kitchens.
Two resolvers
dns.lookup() and dns.resolve()
The two names sound like the same thing. They are not.
dns.lookup() asks the operating system. It calls the OS function getaddrinfo(), the same one almost every other program on the machine uses. The OS may check the local hosts file first, then other local configuration, and only then the network if it still needs to.
dns.resolve() skips the OS. It talks to a DNS server directly, using the DNS protocol itself, through the c-ares library bundled with Node. To libuv this looks like any other network call: a socket, handed to kitchen one.
import dns from 'node:dns';
dns.lookup('payments-service', (err, address) => {}); // thread pool → getaddrinfo()
dns.resolve4('payments-service', (err, addresses) => {}); // network socket, no thread waits
Why one blocks and the other doesn’t
The OS is not the bottleneck here. The issue is what kind of function getaddrinfo() is.
getaddrinfo() is a blocking function. It does not return control until it has an answer. Ordinary network calls work differently: the OS lets you say “start this, and tell me later”, and no thread has to sit and wait for the reply. getaddrinfo() has no such option. There is no “notify me later” version. The only way to call it is to call it, and wait.
That is not Node being lazy. It is a limit in what the OS exposes. So Node needs a thread to sit and wait on your behalf, and that is exactly what the libuv thread pool is for. One of the four threads calls getaddrinfo() and is stuck until the OS replies.
The thread pool is shared
DNS lookups do not get a lane of their own. They share the same four threads with file reads, the asynchronous crypto functions, and zlib compression.
Fire off 20 lookups at once and only four run at a time. The other 16 queue. Behind them, every other piece of thread-pool work queues too, including a file read that has nothing to do with DNS.
This is the thread-pool exhaustion signature from the previous post. Event loop lag stays normal, because the waiter is fine. What rises is the latency of anything that needs a chef.
Who calls which
Who decides which function runs?
Most of the time you never call dns.lookup() or dns.resolve() yourself. Your HTTP client or database driver makes that choice for you.
The default across almost all of Node’s ecosystem is dns.lookup(). Core http and net, most database drivers, axios, and undici (and so fetch) all resolve through it unless you pass something else. It is the right default for correctness: it honours /etc/hosts and the rest of the system’s resolver configuration, exactly like every other program on the box.
The safe assumption is that dns.lookup() is running, unless a library’s documentation says otherwise.
When this becomes a real problem
This is not a constant, hidden danger. It bites when three conditions hold at the same time:
- Many new connections open at once.
- Those connections use hostnames, not raw IPs.
- The thread pool is already busy with other work.
The two services this series keeps returning to sit at different points on that list.
For the Express API in front of Postgres, it mostly shows up at startup, or during a connection storm when the pool reconnects. Not during steady traffic: an open pg connection does not re-resolve DNS for every query.
For the Next.js app with an Express BFF, the risk is higher. If the BFF opens a fresh connection per request to its downstream services, every one of those can trigger a fresh dns.lookup() on the thread pool.
Fan-out
Why microservices make it worse
In Kubernetes, services find each other by name, not by IP. So every call to another service can start with a DNS lookup.
If the BFF fans out to five downstream services per request, that is potentially five lookups per request, all competing for the same four threads. At 100 requests per second, that is up to 500 getaddrinfo() calls a second queueing for four chefs. Under load, it compounds fast.
The fix
Fixing it in two layers
The infrastructure layer
By default, every pod asks CoreDNS for every lookup. That is a network hop, every time.
NodeLocal DNSCache runs a small DNS cache on each node. A pod checks that local cache first, and a hit means no trip to CoreDNS at all. This is a cluster-wide fix, and it belongs with your infrastructure team, not inside your Node app.
The application layer
Avoid dns.lookup() on the hot path. Write your own lookup function instead: it uses dns.resolve() underneath, so it goes to the network rather than the thread pool, and it keeps the answer in memory with a short TTL. The next time the same hostname comes up, check your own cache first. If the answer is still fresh, there is no DNS call at all.
Then pass that function to your HTTP client or database driver. Most clients accept a custom lookup option for exactly this reason. A well-known library for the pattern is cacheable-lookup.
This is implementation work, and it is where the theory ends for this chapter.
Hands-on checkpoint
Three exercises turn this into something you have seen with your own eyes:
- Simulate a connection storm. Fire many new connections to a hostname at once and watch thread-pool contention build from
dns.lookup()calls — and watch an unrelatedfs.readFileslow down with it. - Wire up a custom
lookup. Usedns.resolve()plus an in-memory TTL cache, pass it to your HTTP client, and repeat the storm. - Compare with and without keep-alive on repeated calls to the same host, and count how many lookups each one makes.
Check your understanding
Try each one in your head first, then open the answer to compare.
Why does
dns.lookup()need a thread-pool thread whendns.resolve()does not?dns.lookup()calls the OS functiongetaddrinfo(), which is blocking and has no “notify me later” form. Something has to sit and wait for it, so libuv sends one of its pool threads.dns.resolve()speaks the DNS protocol over an ordinary socket, which the OS can watch for you, so no thread waits at all.Your BFF’s event loop lag is flat, but file reads and outbound calls slow down under load. What do you suspect?
Thread-pool exhaustion. A flat loop means the waiter is fine; the four chefs are busy. In a fan-out service that opens fresh connections per request,
dns.lookup()calls are a prime candidate for what is filling the pool, and everything else that needs a chef queues behind them.Why does an open Postgres connection pool rarely trigger this problem during steady traffic?
DNS only runs when a new connection opens. A pool keeps its connections open and reuses them, so queries never touch DNS. The risk returns only when many connections open together: at startup, or when the pool reconnects after a failure.
Why are there two layers to the fix instead of one?
They remove different costs. NodeLocal DNSCache removes the network hop to CoreDNS for every pod on the node, but your app may still call
getaddrinfo()and occupy a pool thread to reach it. An application-sidelookupbuilt ondns.resolve()with a TTL cache keeps DNS off the thread pool and skips most DNS calls entirely. Together, the lookups that remain are cheap, and none of them waits for a chef.