Module 1

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
  1. dns.lookup() and dns.resolve()
  2. Why one blocks and the other doesn’t
  3. The thread pool is shared
  4. Who decides which function runs?
  5. When this becomes a real problem
  6. Why microservices make it worse
  7. Fixing it in two layers
  8. Hands-on checkpoint
  9. Check your understanding

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
dns.lookup() versus dns.resolve()Your code calling a hostname splits into two possible paths. The left path, dns.lookup(), goes through the libuv thread pool and the OS function getaddrinfo(). The right path, dns.resolve(), goes straight to a DNS server over the network. Both paths end with an IP address returned to your code.Your codefetch("payments-service")dns.lookup()libuv thread pool1 of 4 threads now busyOS: getaddrinfo()IP address found"Kitchen 2" — a thread sits and waitsdns.resolve()OS socketnon-blocking, no thread waitsDNS server (network)IP address found"Kitchen 1" — no thread waitsBack to your codeconnection opens
Both resolvers end the same way — an IP address, handed back to your code. They differ only in which resource waits to get there: a libuv thread, or nothing at all.

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.

The libuv thread pool is shared and limitedSeveral kinds of work — DNS lookups, file reads, zlib and crypto — all arrive at the same thread pool of 4 threads. Once all 4 threads are busy, the rest of the work waits in a single shared queue, regardless of kind, until a thread frees up.Incoming workdns.lookup()dns.lookup()fs.readFile()dns.lookup()zlib / cryptoShared queueone line forevery kind of workone at a timelibuv thread pooldefault size: 4Thread 1 — busy (DNS)Thread 2 — busy (DNS)Thread 3 — busy (fs)Thread 4 — busy (DNS)a slot opens only when a thread finishes its current job
DNS lookups don't get their own lane. They queue behind, and alongside, every other kind of thread-pool work — on the same 4 threads.

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.

BFF fan-out and per-connection DNS lookupsThe BFF calls five downstream services by hostname. For service one, the full path is shown: a DNS lookup through the thread pool and CoreDNS, then a connection opens to the service. Services two through five follow the same DNS step, not drawn again for clarity.Service BBFF, per requestopens 5 new callsdns.lookup()thread pool+ CoreDNSIP returnedconnection opensService 1Service 2Service 3Service 4Service 5Services 2–5 go through the same dns.lookup() → thread pool → CoreDNS step shown above; not redrawn below
Every arrow into services 2 through 5 carries the same hidden DNS step shown in full for Service 1 — one thread-pool round trip per fresh connection, five times over, on every request that doesn't reuse a connection.

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.

Two-layer DNS caching fixTwo independent fixes, each shaped the same way. In the infrastructure layer, a pod asks NodeLocal DNSCache; on a cache hit the IP comes back immediately, on a miss the request goes on to CoreDNS. In the application layer, code asks a custom lookup function; on a cache hit the IP comes back from memory, on a miss dns.resolve() asks a DNS server over the network. Both paths end at the same kind of result box, an IP address returned.Infrastructure layer — cluster-wide, set up with your infra teamPodNodeLocal DNSCacheruns on the same nodehitmissCoreDNSIP returned to podApplication layer — code you write, in your Node processYour codecustom lookup()in-memory TTL cachehitmissdns.resolve()DNS server, networkIP returned to your codeLibrary to know by name: cacheable-lookup
Same shape, two different places. A cache sits in front of the slow path; a hit skips it, a miss still falls back to it correctly.

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:

  1. 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 unrelated fs.readFile slow down with it.
  2. Wire up a custom lookup. Use dns.resolve() plus an in-memory TTL cache, pass it to your HTTP client, and repeat the storm.
  3. 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.

  1. Why does dns.lookup() need a thread-pool thread when dns.resolve() does not?

    dns.lookup() calls the OS function getaddrinfo(), 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.

  2. 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.

  3. 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.

  4. 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-side lookup built on dns.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.