Node.js DNS Resolution: lookup, resolve & c-ares
You write a hostname like example.com and expect a working connection. The socket layer cannot use that hostname directly. It needs an IP address and a port. DNS resolution is the step that turns the hostname into an address the socket can connect to, and it runs before any packet leaves the machine.
Node has two main DNS paths. dns.lookup() asks the operating system to resolve a name, the same style of lookup many socket APIs use before connecting. dns.resolve*() sends DNS protocol queries through c-ares, the DNS library Node uses for record-level DNS lookups.
That split causes a lot of confusion in production. Two calls can both look like "DNS" in your logs while asking different systems and following different rules. dns.lookup() follows host configuration. dns.resolve4(), dns.resolve6(), dns.resolveMx(), and the other resolve*() calls ask DNS servers for specific record types.
DNS Resolution in Node.js
When your code connects to a host, the host is still text at first.
import net from 'node:net';
const socket = net.connect(443, 'example.com');
socket.on('connect', () => {
console.log(socket.remoteAddress, socket.remotePort);
});Node now has a hostname and a port. The hostname is example.com. The port is 443.
The socket layer cannot connect to the text example.com directly. It needs a socket address - an address family, an IP address, and a port. So Node first has to resolve the hostname into one or more candidate addresses. After that, the socket code can try to connect.
DNS is the naming system behind that first step. A name such as example.com can have IPv4 records, IPv6 records, aliases, mail records, text records, service records, and reverse-lookup records. A normal TCP connection only needs an address record, but debugging production DNS often means checking the other record types too.
A domain name is made from labels. api.example.com has the labels api, example, and com. DNS reads that full name as a position inside the DNS hierarchy, with the rightmost labels closer to the top. A hostname is a domain name used for reaching a host. In application code, people use both words loosely. A hostname is the name you pass when you want an address for a network endpoint.
The port is not part of DNS.
net.connect({ host: 'api.example.com', port: 5432 });The resolver handles api.example.com. Your code supplies 5432. Node combines each candidate address with that port and hands the result to the connection code. TCP comes later, then TLS, then HTTP.
Knowing that order helps when you read errors. A DNS error means the name could not be turned into the needed record. A TCP error means Node had an address and the connection step failed. Many client libraries wrap both inside one request error, so keep the lower-level fields in your logs.
socket.on('error', err => {
console.error(err.code, err.hostname, err.address, err.port);
});The fields tell you where the failure came from. A DNS error may include a hostname but no numeric address. A socket error may include an address and port. Keep those separate when debugging.
Numeric addresses skip DNS entirely.
net.connect({ host: '192.0.2.10', port: 5432 });Give Node a numeric IPv4 or IPv6 address and it can build the matching socket address immediately. The OS still has to route the packet, choose a local address, and do the TCP connection work. DNS is gone from the path because the caller already supplied an address.
You can turn this into a debugging move. Replace the hostname with one address that DNS returned. If the numeric connection fails the same way, keep looking below DNS, at routing, the listener, the firewall, TCP, TLS, or the remote service. If it works but the hostname fails, the problem is resolver behavior. When one returned address works and another fails, check address family, routing, firewall rules, listener binds, and fallback behavior.
const socket = net.connect({ host: '2001:db8::10', port: 443 });
socket.on('error', err => {
console.error(err.code);
});No DNS lookup happens here. The connection can still fail because IPv6 routing is missing, the remote endpoint is closed, a firewall blocks it, or TCP setup fails. DNS hands addresses to the socket layer, and the socket layer still has to confirm each one works.
High-level clients hide this work. fetch('https://example.com') parses the URL, extracts the host, resolves it, opens a connection, negotiates TLS, writes HTTP, and reads the response. Later chapters cover those higher layers. For now, stay focused on the first handoff, where a host string goes in and candidate addresses come out.
Some Node client APIs let you override that handoff with a custom lookup function.
import http from 'node:http';
http.get({
host: 'example.com',
lookup(name, opts, cb) {
const found = { address: '104.20.23.154', family: 4 };
cb(null, opts.all ? [found] : found.address, found.family);
}
});A custom lookup changes where the client gets its address, without changing global DNS behavior. Modern client paths may ask the hook for every candidate address with opts.all, so the callback has to return an array when that option is set.
Libraries use this hook for tests, custom routing, metrics, caching, and special resolver policy. It is also a common reason a production client behaves differently from a tiny script that calls dns.lookup() directly.
The Resolver Handoff
A resolver is the code path that answers name questions for a program. Sometimes that path lives in the operating system. Other times it lives in a DNS library that sends DNS packets itself. Node uses both.
The operating-system resolver is the host's configured name-resolution path. On Unix-like systems, it commonly includes /etc/hosts, resolver configuration, search domains, name-service switch rules, and one or more configured DNS servers. On Windows, it uses Windows resolver configuration and policy. The exact behavior depends on the machine.
dns.lookup() uses that OS path through getaddrinfo().
import dns from 'node:dns';
dns.lookup('localhost', { all: true }, (err, addresses) => {
console.log(err ?? addresses);
});localhost often resolves before any DNS packet leaves the machine. A hosts file or OS policy can answer it locally. A hosts file is a static local name-to-address mapping file. On Unix-like systems, it is usually /etc/hosts. On Windows, it lives under the system drivers directory. The exact file path is less important than the behavior. The OS resolver can answer from local configuration before asking DNS.
Local behavior like this can catch you out. dns.lookup('localhost') may return ::1, 127.0.0.1, or both with { all: true }, depending on host policy and Node ordering. A server bound only to 127.0.0.1 listens on IPv4 loopback. A client that picks ::1 tries IPv6 loopback. Those are different socket addresses.
Node also has the DNS-query path. dns.resolve4(), dns.resolve6(), dns.resolveMx(), and the other resolve*() calls use c-ares. c-ares is a C library that performs asynchronous DNS protocol queries. In Node, it backs the APIs that ask configured nameservers for specific record types.
dns.resolve4('example.com', (err, addresses) => {
console.log(err ?? addresses);
});Here c-ares asks for A records. It follows DNS protocol behavior. It does not use the same host-name-service path as dns.lookup(), and it avoids the libuv thread-pool work used for getaddrinfo(), because c-ares integrates with libuv through socket readiness. JavaScript still receives an async callback, but the lower path changed.
So a single module exposes two resolver behaviors.
Short names show this quickly.
dns.lookup('db', { all: true }, console.log);
dns.resolve4('db', console.log);Inside a corporate network or cluster, the OS resolver may apply search suffixes. The bare name db might be expanded using host configuration. The c-ares resolve4() path asks its DNS question through its resolver configuration. Depending on that config, the two calls can disagree and Node can still be behaving correctly.
Record type is another place where the API choice counts. lookup() is for address selection. resolve*() is for DNS record queries. If you want MX or TXT records, use resolveMx() or resolveTxt(). getaddrinfo() gives address information for connection setup, not mail routing data or verification strings.
This shows up in real libraries. A database client calls dns.lookup() because it wants a socket. A config validator reaches for dns.resolveSrv() to read service records. A platform health check uses resolve4() to ask a specific resolver for A records. In a loose log message all three are just "DNS," but they are different resolver behaviors inside the process.
The Recursive DNS Path
Most applications do not query the whole DNS hierarchy themselves. They ask a resolver near them, and that resolver may already have the answer cached.
Here is the usual path.
Node process
-> local resolver path
-> recursive resolver
-> root nameserver
-> TLD nameserver
-> authoritative nameserver
-> answer recordsA recursive resolver accepts a DNS question from a client and does the follow-up work to find an answer. Your laptop might use a router, ISP resolver, corporate resolver, cloud VPC resolver, or public resolver. A container often inherits resolver settings from the host or runtime, and a Kubernetes pod usually tries cluster DNS first. Whatever the source, the application sees one configured resolver address and sends its questions there.
Root nameservers sit at the top of the DNS hierarchy. They know where to send questions for top-level domains. A top-level domain nameserver holds delegation data for a suffix such as com, org, or io. An authoritative nameserver owns the DNS records for a zone and can answer for names inside it.
An uncached lookup for api.example.com runs roughly like this.
recursive resolver asks root for api.example.com
root answers - ask .com servers
recursive resolver asks .com for api.example.com
.com answers - ask example.com authoritative servers
recursive resolver asks authoritative for api.example.com
authoritative answers - records for api.example.comReal DNS packets include response codes, additional records, glue records, EDNS options, UDP or TCP transport choices, retries, and timeouts. Backend Node code usually does not need every field. For day-to-day work, each step does one of three things. It gives the answer, points to the next nameserver, or reports a failure.
After the recursive resolver gets the answer, it returns it to the client and usually caches it. Future clients asking that same resolver for the same record can receive the cached answer until the record's TTL expires.
TTL means Time To Live. In DNS, a TTL is the number of seconds a resolver may cache a record answer before treating it as expired. Authoritative nameservers attach TTLs to records. Recursive resolvers count them down. Depending on the API, clients may see the remaining TTL or only the record data.
This cache layer explains why two machines can disagree after a DNS change. One resolver may still hold an old answer while another has already fetched the new one, and a third applies its own TTL policy. The authoritative zone can be correct while some clients still see older cached data.
Failures can be cached too. A resolver can remember that a name or record type had no answer for a while. The cache lifetime comes from DNS response metadata and resolver policy. So a name created moments after a failed lookup may still fail from the same network path, because the resolver is still holding the earlier "no answer" result.
DNSSEC and DNS over HTTPS add more behavior. DNSSEC adds cryptographic validation for DNS data. DNS over HTTPS sends DNS queries over HTTPS instead of the usual resolver transport path. Node's ordinary dns.lookup() and dns.resolve*() APIs still leave your program dealing with the resolver behavior visible from the process.
A DNS query has a name, a type, and a class. For Node backend work, the class is almost always Internet class. The type is the record kind - A, AAAA, MX, TXT, SRV, and so on. The name is encoded as DNS labels rather than as a URL string. A query for api.example.com type A means "give me IPv4 address records for this DNS name."
A response can contain several sections. The answer section contains records that answer the question. The authority section can point at the nameservers responsible for the next part of the hierarchy. The additional section can carry records that help the resolver continue, such as addresses for nameservers mentioned in a delegation. Backend code usually sees the final parsed answer. The recursive resolver uses those sections while finding it.
Delegation is how different nameservers own different parts of DNS. The root zone delegates com to TLD nameservers. The com zone delegates example.com to that domain's authoritative nameservers. The authoritative zone for example.com can answer for api.example.com, or it can delegate a lower subdomain to another nameserver set.
Authoritative nameservers can return several outcomes.
answer records exist
name exists, requested type has no data
name is an alias through CNAME
name is absent
server failed to answer correctlyThose outcomes later become Node error codes, empty-looking results, or record arrays. The mapping varies because OS resolver APIs and c-ares expose errors differently. Still, the outcome tells you where to look. Missing AAAA data points at record type or address-family setup. A missing name points at naming or delegation, and a server failure points at the resolver path or the authoritative server.
CNAME handling is easy to overlook. If api.example.com is a CNAME to edge.example.net, an A query for api.example.com can return the alias record and address records for the target. Resolvers may cache each part of that chain using that record's own TTL. After a change, one resolver can hold the old alias while another has already fetched the new target. The symptom is one name returning different address sets from different networks.
DNS usually uses UDP for ordinary queries because a single request and response fit. Larger responses, truncation, zone transfers, and some resolver policies switch to TCP. Node code using resolve*() leaves that transport choice to c-ares and resolver behavior. All of this happens before your TCP connection to the application server begins.
Recursive resolvers can also apply policy. They can block names, rewrite internal zones, synthesize answers, prefer local replicas, clamp TTLs, or forward different domains to different upstream resolvers. Corporate networks, cloud VPCs, and clusters do this heavily. From inside Node the result is just "the resolver answered," but that answer may have passed through local policy first.
Comparing production against a public resolver can mislead you for the same reason. If production uses an internal resolver that knows db.service.internal, a public resolver returning ENOTFOUND tells you almost nothing about production. Match the resolver path before comparing answers.
Recursive resolution has limits built in too. A resolver can stop after too many CNAME hops, reject malformed responses, retry another authoritative server when one fails, fall back to TCP after a truncated UDP answer, or return a cached failure when upstream servers are unreachable and local policy allows it. None of this lives in Node, but it determines the errors and latency your process sees.
Latency in this path has several sources. A cold recursive lookup may need several network round trips before Node gets one address candidate. A warm resolver cache can answer in a single local round trip, and a hosts-file answer through dns.lookup() can complete with no DNS network traffic at all. If the first outbound request after startup is slow, a cold DNS cache is one possible reason. Connection pool warmup and TCP or TLS setup are others, so measure the phases separately.
The DNS hierarchy also explains partial outages. When the authoritative nameservers for example.com are unhealthy, names below that zone can fail while unrelated domains keep working. When one resolver network struggles to reach a TLD path, domains under that suffix look broken from that resolver and fine everywhere else. When a single record type is misconfigured, A can work while AAAA fails. The question to ask is always the narrowest one. Which name, which type, which resolver path, and when.
Records Node Developers Actually Touch
A DNS record is typed data attached to a name. The type tells the resolver how to interpret the data.
Address records are the ones most Node developers use first.
dns.resolve4('example.com', (err, records) => {
console.log(records);
});An A record maps a name to an IPv4 address. An AAAA record maps a name to an IPv6 address. A name can have several records of the same type. The returned order can vary based on resolver behavior, authoritative server behavior, load-balancing setup, and client ordering rules.
dns.resolve6('example.com', (err, records) => {
console.log(records);
});An address family is the protocol family for an address. In Node networking APIs, you mostly see 4 for IPv4 and 6 for IPv6. You may also see strings such as IPv4 and IPv6 in APIs like os.networkInterfaces(). The family is part of the socket address. 127.0.0.1:3000 and ::1:3000 are different endpoints.
A CNAME record says one DNS name is an alias for another DNS name. Resolvers follow the alias and return the final records requested by the client, subject to normal DNS and cache behavior.
dns.resolveCname('www.iana.org', (err, names) => {
console.log(err?.code ?? names);
});CNAMEs show up often in production because the hostname in your config may be an alias. The address records may actually live on the target name. If you debug only the visible name, you can miss the owner of the address answer.
CNAMEs also change rollout timing. A team may own api.company.test, while a platform provider owns the target name and its address records. The CNAME can have one TTL, and the target address records can have another. Resolvers cache each record according to its own TTL. A change becomes visible only when the relevant cached parts have expired along the path you are observing.
MX records define mail exchangers for a domain. Node backend services usually touch them when validating mail routing or building mail checks.
dns.resolveMx('example.com', (err, records) => {
console.log(records);
});TXT records attach text values to a DNS name. They appear in ownership verification, SPF, DKIM, DMARC, and many platform verification flows. DNS only carries the text chunks. The application or external service decides what those chunks mean.
dns.resolveTxt('example.com', (err, records) => {
console.log(records);
});SRV records describe service endpoints with priority, weight, port, and target name. Some systems use them to locate protocol endpoints without hard-coding a host and port in application config.
dns.resolveSrv('_sip._udp.sip2sip.info', (err, records) => {
console.log(err?.code ?? records);
});Service discovery as a broader architecture topic is a later concern. At this level, an SRV record is just a DNS record whose data includes a port and a target name, and no socket has opened yet.
SRV records are one of the few common DNS records that carry a port. Code that understands SRV has to read that port. net.connect('service.example.com') uses ordinary address lookup for the host it receives. If a protocol expects SRV, the client library has to implement that DNS lookup path.
PTR records support reverse lookups. They map an address-derived DNS name back to a domain name. Node exposes this through dns.reverse().
dns.reverse('8.8.8.8', (err, hostnames) => {
console.log(err ?? hostnames);
});Reverse DNS is operational data. It can help with logs, mail systems, and diagnostics. Treat it as a DNS answer and nothing more. Certificate validation, HTTP host routing, proxy headers, and application identity all belong to higher layers.
NS and SOA records are useful for debugging even when application code rarely consumes them. NS records name the authoritative nameservers for a zone or delegation. SOA records carry zone metadata, including values that affect negative caching. Node exposes resolveNs() and resolveSoa() for these. Use them when you need to know which nameserver owns an answer or why absence may be cached for a while.
dns.resolve() can request a type by string.
dns.resolve('example.com', 'MX', (err, records) => {
console.log(records);
});Most code is clearer with the typed helpers because each record type has its own return structure. resolve4() returns address strings by default. resolveMx() returns objects with exchange and priority fields. resolveTxt() returns arrays of string chunks because one TXT record can contain multiple character strings.
Always log the record type. A name can have A records but no MX records, or TXT records but no address records. It can be delegated correctly and still lack the exact type your code asked for. If your logs include only the name, you lose half the DNS question.
async function readTxt(name) {
try {
return await dns.resolveTxt(name);
} catch (err) {
console.error({ name, type: 'TXT', code: err.code });
throw err;
}
}The wrapper records the actual DNS question. During an incident, "TXT lookup for this name returned this code" tells you far more than "lookup failed."
TTLs, Caches, and Stale Answers
DNS bugs are often inconsistent because there are caches in several places.
The authoritative nameserver has the current zone data. A recursive resolver may hold cached answers, the operating system may cache names, and a runtime, library, or connection pool may keep resolved addresses or open sockets. A cloud load balancer can update DNS before every client has dropped its old connections. Each of those is a separate source of state.
Small TTLs reduce how long resolvers should normally cache an answer. They also increase query volume. Large TTLs reduce query volume and make planned changes slower to appear. There is no Node setting that makes every resolver forget an answer it already cached.
Node's dns module does no caching of its own. Every dns.lookup() and dns.resolve*() call sends a fresh query. Any caching you see comes from the recursive resolver, the OS resolver, or your own code. If your service needs a process-local DNS cache, you write it, and that cache has to honor TTLs and negative answers itself.
dns.resolve4() can ask Node to include TTL values.
dns.resolve4('example.com', { ttl: true }, (err, records) => {
console.log(records);
});The records come back as objects with address and ttl. That TTL is the resolver's reported remaining TTL for the answer. Another client using another recursive resolver can see a different number or a different answer.
dns.lookup() has a different return structure. It asks the OS for address information through getaddrinfo(). The OS resolver may use DNS, hosts files, mDNS, enterprise name services, or local cache policy. The result is address information for a socket. Raw DNS TTL values do not come back through this API.
dns.lookup('api.example.com', { all: true }, (err, addresses) => {
console.log(addresses);
});After a deployment updates api.example.com, repeated calls can produce mixed answers for a while. One process might use a cached old address while another asks a resolver that already fetched the new one. A long-lived HTTP client may keep an existing TCP connection to the old address and skip the lookup entirely for its next request. DNS changed, but the client kept using a socket it already had.
Hosts-file overrides make the behavior local.
127.0.0.1 api.example.testAfter that entry exists in the host resolver path, dns.lookup('api.example.test') can return 127.0.0.1. dns.resolve4('api.example.test') asks DNS servers for A records and can fail because the hosts file belongs to the OS resolver path. This is one of the fastest ways to learn which Node API a dependency is using.
dns.lookup() goes through the OS resolver path, so it reads /etc/hosts entries, search domains, and name-service rules. dns.resolve*() sends DNS protocol queries through c-ares and ignores all of that local configuration. A hosts-file override that makes dns.lookup('api.example.test') return 127.0.0.1 has no effect on dns.resolve4('api.example.test'), which still asks the configured nameservers.
Negative caching follows the same idea. If new-api.example.com has no answer at 10:00 and the record is created at 10:01, some resolvers may keep reporting the earlier failure until the negative cache entry expires. A retry loop inside Node cannot force that resolver to refetch early through ordinary DNS APIs.
Containers and VMs add more layers through generated resolver config. A container may point at an internal DNS forwarder, which in turn points at the host or cluster resolver. The recursive resolver the process actually reaches can be several hops from the authoritative nameserver. The Node API still reports one async result.
When debugging, pin the question down.
name - api.example.com
type - A or AAAA
resolver path - OS lookup or DNS query
resolver server - which IP, if known
observed answer - addresses and TTL, if available
time - when the answer was observedWithout those fields, "DNS is wrong" can mean almost anything. The name might be wrong, or the record type might be missing. The recursive resolver might be stale, or the OS resolver might be reading a hosts file. The application might even be reusing an old connection and doing no lookup at all.
Node's DNS module is a thin API over lower resolver behavior. Ordinary dns.resolve*() calls go through c-ares and the configured resolver path, and ordinary dns.lookup() calls go through the OS resolver path. Any caching happens below Node or in your own code. A process-local cache with custom expiration has to honor TTLs deliberately.
Long-lived clients create another kind of stale behavior by skipping lookups. An HTTP agent keeps a socket open, a database pool keeps connections open, and a gRPC channel holds its subchannels. While those connections stay alive, the client keeps talking to old addresses after DNS has changed. The cause is connection reuse rather than DNS caching. From the outside the symptom is the same as stale DNS, because a fresh lookup would pick different addresses.
Rolling changes need two timelines.
DNS TTL controls cached answers
connection pooling controls old sockets
retry policy controls when new addresses are triedIf a service moves from one address to another, lowering TTL shortly before the move helps only for clients that perform new lookups after the lower TTL has reached their resolver path. Clients holding existing sockets keep using those sockets until they close, fail, or are retired by pool policy. So infrastructure changes often need connection draining as well as a DNS change.
A hosts-file override creates another stale-answer case. A developer can add a local override during testing and forget it. dns.lookup() follows the OS resolver path and keeps returning the override. dns.resolve4() may return the real DNS answer. The application seems broken only on that machine. Compare OS lookup with DNS-query behavior, then inspect local resolver config.
Search domains can add silent suffixes. A bare name such as api may resolve to api.corp.example through OS policy. Another machine with a different search suffix may report failure. Code that relies on bare names relies on resolver configuration. That can be fine inside a controlled network, but it should be treated as deployment config rather than a general DNS fact.
TTL zero needs careful wording. Some records use TTL 0 to ask resolvers to avoid caching or cache only briefly. Resolver implementations and intermediate systems can still apply local policy. Treat TTL 0 as a request carried in DNS data, then verify the resolvers your process actually uses.
Application retries can make resolver problems worse. Retrying the same name through the same resolver just repeats the cached answer until the TTL expires. A numeric address skips the resolver and tests the next layer down, while a different resolver tests resolver-specific cache and policy. Those are three different experiments, so pick the one that matches the failure.
A process can also create its own stale value by storing an address. DNS TTLs govern resolver caching. Once your application stores an address in a variable, your code controls how long it lives.
const addresses = await dns.resolve4('api.example.com');
setTimeout(() => connectTo(addresses[0]), 60_000);Here the address is held for a minute regardless of the DNS TTL. That might be fine, or it might be a bug. Either way, the resolver cannot update a value your code has already stored.
Node's Two DNS Paths
The node:dns module exposes one namespace over two lower mechanisms.
dns.lookup() is the address lookup API used by many Node networking calls. net.connect(), http.request(), https.request(), and higher clients commonly use lookup-style behavior before opening a socket, unless they provide a custom lookup function or already have a numeric address.
dns.lookup('example.com', (err, address, family) => {
console.log(address, family);
});Internally, Node calls getaddrinfo() through libuv. getaddrinfo() is the system API that converts a node name and service hints into address structures. Here the output is candidate IP addresses and address-family data. The OS resolver decides the policy, which covers the hosts file, search suffixes, address-family availability, the local cache, and platform-specific name services.
getaddrinfo() can block at the C API level while the OS resolver does its work. Node keeps the JavaScript thread free by running that work through the libuv thread pool. Many concurrent dns.lookup() calls can occupy worker threads that file-system work, crypto work, and other thread-pool tasks may also need.
dns.lookup() runs getaddrinfo() on the libuv thread pool. That pool defaults to four threads and is shared with fs and crypto work. A burst of slow lookups against an unresponsive resolver can take every worker and stall unrelated file and crypto work in the same process. Raise UV_THREADPOOL_SIZE for lookup-heavy workloads, or move record queries to dns.resolve*(), which runs through c-ares and the event loop instead of the pool.
Step by step, the call path is this.
JavaScript dns.lookup()
-> Node dns binding
-> libuv uv_getaddrinfo request
-> libuv thread pool
-> OS getaddrinfo()
-> callback on the event loopuv_getaddrinfo is libuv's wrapper around the platform address-lookup API. Node creates a request, attaches the JavaScript callback state, and asks libuv to run the lookup away from the main JavaScript thread. When the worker finishes, libuv posts completion back to the event loop. Node converts the native address structures into JavaScript strings and family numbers.
This keeps slow resolver work away from JavaScript execution. Timers still fire, open sockets still read, and promises still settle while the lookup callback arrives later. The trade-off is worker-pool pressure. A burst of slow OS lookups shares that finite pool with file-system and crypto work.
for (const host of hosts) {
dns.lookup(host, err => {
if (err) console.error(host, err.code);
});
}The loop lets JavaScript continue while the callbacks are pending, but it can still create pressure lower down. If the OS resolver is slow, those lookup jobs pile up in the pool, and file-system work, crypto work, and other pool users can slow down depending on the process and UV_THREADPOOL_SIZE.
This pool pressure is one reason high-throughput clients sometimes add a lookup cache or a custom resolver strategy. That choice adds responsibility. Once the application caches DNS answers, it has to handle expiration, negative caching, address-family ordering, and retry policy itself. A weak cache can hold dead addresses longer than the recursive resolver would have, and a cache that ignores failed lookups can keep querying a resolver during an outage and make it worse.
Node networking APIs often expose the lookup hook because the runtime cannot know every deployment policy. The hook follows dns.lookup() semantics - hostname, options, callback with address and family.
function lookup(host, options, callback) {
dns.lookup(host, { ...options, all: false }, callback);
}Real custom lookup functions usually do more than wrap dns.lookup(). They might record timing, cache answers, prefer private addresses, or read from a service registry. The contract is narrow. The caller expects a usable address and family for a socket attempt. Returning an address with the wrong family creates socket failures that may look unrelated to DNS.
With { all: true }, the custom lookup contract changes. Some clients request all addresses when address-family autoselection is active.
function lookup(host, options, callback) {
callback(null, [{ address: '127.0.0.1', family: 4 }]);
}A hook should honor the options object it receives. If the client asks for family: 6, returning IPv4 breaks the caller's policy. If the client asks for all addresses, returning only one address can disable fallback behavior. The hook is small, but it sits in a sensitive place.
dns.resolve*() uses c-ares for DNS protocol queries. c-ares creates sockets, sends DNS messages to configured nameservers, and integrates completion with the event loop. It asks for DNS records by type. It bypasses most OS name-service policy, including hosts-file answers.
dns.resolve6('example.com', (err, addresses) => {
console.log(err ?? addresses);
});Here c-ares asks the configured DNS servers for AAAA records. If the hosts file contains an IPv6 mapping for example.com, resolve6() still goes to DNS. If DNS has no AAAA record, the call reports the result for that record type.
Here is the c-ares path.
JavaScript dns.resolve4()
-> Node dns binding
-> c-ares channel
-> DNS query socket
-> configured nameserver
-> callback on the event loopc-ares manages the DNS query state. It tracks outstanding queries, server choices, retries, timeouts, and response parsing. libuv watches the sockets c-ares needs to read or write. When a DNS response arrives, c-ares parses it and Node converts the record data into JavaScript return values.
That path has no getaddrinfo() call and no OS hosts-file lookup. The configured nameserver IPs may still come from OS resolver configuration, but once Node runs the c-ares query, the query behavior belongs to c-ares.
A lot of bugs hide in that detail. The c-ares path can use the same configured nameserver IPs as the OS resolver and still behave differently, because it skipped local hosts-file data, name-service switch rules, some search handling, local caches, or enterprise plugins. The nameserver address is identical, yet the client behaves differently.
c-ares query load can also appear as UDP or TCP traffic from the process to resolver servers. In locked-down environments, egress rules may allow the OS resolver service path and block direct DNS traffic from application containers. In that setup, dns.lookup() can succeed while dns.resolve4() times out or gets refused. The failure comes from network policy around DNS query traffic, and has nothing to do with the target service.
The reverse can happen too. A container image may have broken OS lookup policy, while direct c-ares queries to a known server work. Comparing both paths tells you whether to inspect local name-service configuration or DNS server behavior.
dns.setServers() belongs to the c-ares path.
dns.setServers(['1.1.1.1', '8.8.8.8']);
dns.resolve4('example.com', (err, addresses) => {
console.log(addresses);
});This changes the servers used by resolve*() calls in the current process. The OS resolver behind dns.lookup() keeps its own configuration. /etc/resolv.conf, Windows resolver settings, and other host policy outside Node's c-ares state stay exactly as they were.
For isolated resolver settings, use a Resolver instance.
const resolver = new dns.Resolver();
resolver.setServers(['8.8.8.8']);
resolver.resolve4('example.com', (err, addresses) => {
console.log(addresses);
});The instance keeps its own server list for c-ares queries, separate from dns.lookup() and from the global resolver's server list.
Per-instance resolvers help with diagnostics because they make the resolver server explicit without changing the rest of the process.
const corp = new dns.Resolver();
const pub = new dns.Resolver();
corp.setServers(['10.0.0.53']);
pub.setServers(['1.1.1.1']);Ask both resolvers for the same record type. If only the corporate resolver answers, the name is internal. When both answer but disagree, compare TTLs and authority. If both fail while lookup() still succeeds, inspect hosts files, search domains, and OS name services.
A Resolver instance is also useful for test isolation. A test can point one resolver at a local DNS server without changing global c-ares servers for unrelated tests in the same process. It still uses real DNS protocol behavior. For pure unit tests, a custom lookup function or direct dependency injection is usually cleaner than depending on live DNS.
The promise API mirrors the callback API.
import { promises as dns } from 'node:dns';
const records = await dns.resolve4('example.com');
console.log(records);Promises change the JavaScript style only. dns.promises.lookup() still uses lookup behavior. dns.promises.resolve4() still uses c-ares query behavior.
Choose the API based on the job.
Use dns.lookup() when you want the address-selection behavior Node would use for a real connection to that host. It follows local host policy, sees hosts-file entries, and hands back address-family data ready for a socket.
Use dns.resolve*() when you need DNS records as records. MX checks, TXT verification, SRV reads, TTL inspection, and "what does this resolver say for AAAA" all go through that path.
The common mistake is testing a connection path with the wrong API. A production client may use dns.lookup() through http.request(). A debugging script may use dns.resolve4() and get another answer. Both results can be correct because they asked different resolver paths different questions.
Another common mistake is using dns.lookup() with the default single-address result during debugging.
dns.lookup('example.com', (err, address, family) => {
console.log(address, family);
});The callback receives one selected address. If the name has ten address records across two families, this still prints just one. For debugging, pass { all: true } and record the whole candidate list. For connection setup, one selected address can be enough when the client has fallback behavior elsewhere.
lookupService() goes the other way. It maps a numeric address and port to a hostname and service name through the OS path.
dns.lookupService('127.0.0.1', 80, (err, host, service) => {
console.log(host, service);
});This is reverse lookup plus service-name mapping through system facilities. It helps with diagnostics. Peer validation and application authentication happen at higher layers.
lookup() also supports address-family options.
dns.lookup('example.com', { family: 6 }, (err, address) => {
console.log(address);
});This asks for an IPv6 result. With { all: true }, it returns every address selected by the OS lookup path and Node's ordering rules.
dns.lookup('example.com', { all: true }, (err, addresses) => {
console.log(addresses);
});The objects look like { address: '...', family: 4 } or { address: '...', family: 6 }, which is close to what the connection attempt later needs.
resolve4() and resolve6() ask for the two address record types separately.
const [v4, v6] = await Promise.all([
dns.resolve4('example.com'),
dns.resolve6('example.com')
]);This runs two DNS questions in parallel. Socket attempt order is a later connection-policy decision, and fallback policy, local routing, and Happy Eyeballs behavior all come after DNS. The DNS part stops at address candidates.
Address Family and Lookup Ordering
Lookup ordering decides which candidate address appears first when a name has several usable answers.
In Node v24, dns.getDefaultResultOrder() reports the process default order. On a stock Node v24 build, it commonly reports verbatim, which means Node preserves the order returned by the resolver path instead of forcing IPv4 first.
Current Node defaults dns.lookup() ordering to verbatim. It keeps the order the resolver returned rather than sorting IPv4 ahead of IPv6. On dual-stack hosts an AAAA answer can be tried first. Earlier Node versions defaulted to IPv4-first, so code that relied on that order can reach a different address family after an upgrade.
console.log(dns.getDefaultResultOrder());The process default can be changed.
dns.setDefaultResultOrder('ipv4first');Current Node supports order values such as verbatim, ipv4first, and ipv6first. The setting affects dns.lookup() result ordering. Code can also pass an order for a specific lookup.
dns.lookup('localhost', { all: true, order: 'ipv4first' }, (err, out) => {
console.log(out);
});Ordering is visible because clients often try the first address before the rest. If localhost returns ::1 first and a server listens only on 127.0.0.1, the first connection attempt targets IPv6 loopback. Some client paths then fall back to IPv4, some surface the first failure, and higher-level clients each have their own behavior.
dns.lookup() also accepts family.
dns.lookup('localhost', { family: 4 }, (err, address) => {
console.log(address);
});family: 4 asks for IPv4. family: 6 asks for IPv6. family: 0 allows either family. Address-family filtering happens before the connection attempt. The socket path receives the selected family.
There are resolver hints too, such as dns.ADDRCONFIG and dns.V4MAPPED. They map to getaddrinfo() hint behavior where the platform supports it. ADDRCONFIG asks the OS to consider configured local address families. V4MAPPED can request IPv4-mapped IPv6 addresses in contexts where that makes sense. Platform behavior varies, so use these only when you have a specific reason and tests on the target hosts.
Avoid using lookup ordering to hide a server that binds too narrowly. If a server must support both loopback families, bind and test both families under the intended platform policy. If a client must prefer IPv4 or IPv6, make that policy explicit at the lookup or connection layer and put it in configuration.
To see the whole set, pass { all: true }.
dns.lookup('api.example.com', { all: true }, (err, addresses) => {
console.table(addresses);
});Seeing every candidate makes the next failure easier to place. If IPv6 is missing, check DNS records or resolver policy. If IPv6 is present but the connect fails, look at the route, listener, firewall, or transport. When IPv4 is present but the client ignores it, check ordering and fallback policy.
Ordering is process state. Code that changes the default result order should do it during bootstrap, near the rest of your runtime configuration. Libraries should usually prefer per-call options over changing global DNS behavior for the whole process. A package that calls dns.setDefaultResultOrder() during import can change unrelated clients in the same process.
dns.setServers() and dns.setDefaultResultOrder() change the global resolver state for the whole process, so a library that calls either at import time reconfigures every unrelated client too. Use a new dns.Resolver() instance for scoped nameservers, and pass the order option per call instead of changing the process default. Keep the global setters for application bootstrap you control.
Use the narrowest control that fits.
dns.lookup(host, { all: true, order: 'ipv6first' }, cb);This call states its own policy, and the rest of the process keeps its default. Tests are easier to read too, because the ordering dependency sits right next to the assertion.
Happy Eyeballs, the connection strategy that races or staggers address-family attempts, comes up in the full request-path chapter. For DNS, the output is a set of candidate addresses and an order. Deciding what to try, and when, is the socket layer's job.
Failure Codes and Debugging
DNS errors are easier to read when every log line includes the API path, name, and record type.
ENOTFOUND means the chosen resolver path could not resolve the requested name. For dns.lookup(), it usually means the OS resolver path produced no address for that hostname. For dns.resolve*(), it usually maps to a DNS no-such-name style result from the DNS query path.
dns.lookup('missing.example.invalid', err => {
console.error(err.code);
});EAI_AGAIN means a temporary resolver failure. The name might work later. Common causes include DNS server timeout, transient resolver failure, network loss between the host and resolver, or local resolver trouble. Treat it differently from a stable "this name is absent" result.
ENODATA means the name exists in the DNS path, but the requested record type has no data in the observed answer. A name can have A records and no MX records, or A records and no AAAA records. That is not the same as the whole name being absent.
dns.resolveMx('example.com', err => {
if (err) console.error(err.code);
});The exact code can vary by API, platform, resolver, and timing. Keep logs specific.
function logDnsError(name, type, err) {
console.error({ name, type, code: err.code, errno: err.errno });
}For connection-heavy code, log the lookup result and connect error separately.
dns.lookup(host, { all: true }, (err, addresses) => {
if (err) return console.error('lookup', err.code);
console.log('lookup', addresses);
});Then test the numeric address path. If a numeric connect to the returned address fails, DNS gave you an address and the transport path failed. If lookup fails before any address appears, the socket path has not started yet.
ENOTFOUND from an HTTP client can hide the lower DNS operation. Get the original error when possible. Node errors often carry code, errno, syscall, and hostname. Some client libraries wrap those fields. Good wrappers preserve them. Losing hostname and code turns a useful resolver failure into a generic request failure.
EAI_AGAIN needs careful retries. Retrying immediately from thousands of requests can make resolver trouble worse. Use bounded retries and jitter if retrying name resolution is part of the client policy. Keep the DNS retry budget separate from TCP retry or HTTP retry budgets because they fail at different layers.
ENODATA should send you to the record type. If resolve6() returns ENODATA while resolve4() returns addresses, the service may be IPv4-only. If resolveMx() returns ENODATA, the domain may not receive mail directly. The name can still exist. The requested data is absent.
Other DNS codes show up too. ESERVFAIL points at a server-failure response, ETIMEOUT at a query timeout in the DNS-query path, and ECONNREFUSED can appear when the configured DNS server rejects the query transport. Keep the resolver path, name, type, and server attached to the error.
The c-ares path can be isolated with Resolver.
const r = new dns.Resolver();
r.setServers(['8.8.8.8']);
r.resolve4('api.example.com', (err, records) => {
console.log(err?.code ?? records);
});Compare that with the process's normal lookup.
dns.lookup('api.example.com', { all: true }, (err, out) => {
console.log(err?.code ?? out);
});Different results point you toward the relevant layer. A hosts-file override shows up in lookup() and disappears from resolve4(). A custom c-ares server affects the resolver instance while leaving lookup() alone. A corporate resolver can answer internal names that a public one cannot, and a search suffix can make a short hostname work through the OS path even when a DNS protocol query for the bare name fails.
Search domains are another OS resolver feature. A host can be configured so a short name such as db is tried with configured suffixes. dns.lookup('db') may resolve through that policy. dns.resolve4('db') asks DNS for the name through the c-ares path. Internal infrastructure relies on this often enough that you should log the path during debugging.
Kubernetes makes this visible. A pod often has search domains for its namespace and cluster. A short service name can resolve through OS lookup because resolver config expands it. A direct DNS query for that short name may behave differently depending on c-ares handling of the resolver config and options. For portable diagnostic scripts, query the fully qualified service name and record which API you used.
Cloud private DNS behaves the same way at a larger scope. A name can resolve inside a VPC and fail from a laptop, and a public resolver can return no answer while the cloud resolver returns private addresses. The difference is resolver scope, so match the source network and resolver before comparing results.
Timeout labels need care. A request timeout in an HTTP client may include DNS time, TCP connect time, TLS handshake time, request write time, server processing time, and response read time. A DNS timeout code from node:dns is narrower. It means the resolver operation failed to produce an answer in time. Keep timeout labels exact.
For local debugging, start with names that avoid external DNS.
dns.lookup('localhost', { all: true }, console.log);This checks the OS lookup path and local host policy. Then test a real DNS query.
dns.resolve4('example.com', console.log);The first can succeed with no DNS server. The second needs DNS query behavior. If the first works and the second times out, your process can use local resolver policy but cannot complete c-ares DNS queries to its configured servers. If the second works and a dependency using lookup() fails, inspect hosts files, search domains, OS resolver config, and thread-pool pressure.
One last address-family check. Log every answer, including the one the client actually tried.
dns.lookup(host, { all: true, order: 'verbatim' }, (err, all) => {
console.log(all);
});A name can resolve correctly and still hand the next layer an address the host cannot use. IPv6 DNS may exist while IPv6 routing is broken, or IPv4 may work while an IPv6-first path fails early. DNS produced the candidates, and the socket path still has to confirm each one.
A compact DNS debug script should print both main paths.
const host = process.argv[2];
dns.lookup(host, { all: true }, (err, out) => {
console.log('lookup', err?.code ?? out);
});
dns.resolve4(host, (err, out) => {
console.log('A', err?.code ?? out);
});The script checks OS lookup and A-record DNS query behavior. Add resolve6() when address-family behavior is part of the bug, and add a Resolver instance when the resolver server IP is important. Reach for numeric net.connect() only after you have candidate addresses.
When you hit a DNS incident, this order works well.
capture the failing hostname and record type
check which lookup path the code uses
print every address candidate
compare OS lookup with c-ares query behavior
test one numeric socket address
inspect caches and TTLsThat sequence keeps a TCP failure from being mislabeled as DNS, keeps a hosts-file override from being mistaken for authoritative DNS, and keeps an IPv6 route problem from hiding under "hostname failed."
When the failure sits inside a larger client, add timing around the lookup step. A custom lookup wrapper can record start time, end time, hostname, options, and result count. Keep it temporary if the client library already has tracing hooks.
const timedLookup = (host, opts, cb) => {
const started = performance.now();
dns.lookup(host, opts, (err, address, family) => {
console.log(host, performance.now() - started);
cb(err, address, family);
});
};The wrapper measures the OS lookup path only, and TCP connect time sits below it. If the lookup finishes quickly and the request still stalls, move down to the socket path. If the lookup eats most of the request budget, stay with resolver config, cache state, and thread-pool pressure.
Name resolution ends at addresses. After that, control returns to the socket primitives, including address family, socket address, route lookup, kernel socket state, and the connection lifecycle that TCP manages.