Node.js DNS Resolution: lookup, resolve & c-ares
Node.js DNS resolution is the step that turns a hostname like example.com into an address that a socket can actually use. Your code starts with a name. The socket layer needs an IP address and a port. DNS sits between those two points.
Node has two main DNS paths. dns.lookup() asks the operating system to resolve a name. That is the same style of lookup many socket APIs use before connecting. dns.resolve*() sends DNS protocol queries through c-ares, which is the DNS library Node uses for record-level DNS lookups.
That one detail explains a lot of production confusion. Two calls can both look like "DNS" in your logs, but they may ask different systems and follow 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);
});That call gives Node 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. The practical version is simple - 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 then hands the result to the connection code. TCP opens later. TLS checks happen later. HTTP happens later.
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. It saves a lot of time.
Numeric addresses skip DNS entirely -
net.connect({ host: '192.0.2.10', port: 5432 });With a numeric IPv4 address, Node can build an IPv4 socket address immediately. With a numeric IPv6 address, Node can build an IPv6 socket address immediately. The OS still has to route the packet, choose a local address, and perform the TCP connection work. DNS is gone from the path because the caller already supplied an address.
That gives you a clean debugging move. Replace the hostname with one address that DNS returned. If the numeric connection fails the same way, keep looking below DNS - routing, listener, firewall, TCP, TLS, or the remote service. If the numeric connection works but the hostname fails, stay with resolver behavior. If 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);
});That code performs no DNS lookup. It can still fail because IPv6 routing is missing, the remote endpoint is closed, a firewall blocks the connection, or TCP setup fails. DNS gives addresses to the socket layer. The socket layer still has to prove those addresses are usable.
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 own those higher layers. For this chapter, keep the first handoff in view - host string in, candidate addresses 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);
}
});That hook changes where the client gets its address. It does not change global DNS behavior. Modern client paths may ask the hook for every candidate address with opts.all, so the callback has to return the array shape 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. Sometimes 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.
That local behavior can surprise you. 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);
});That call asks for A records. It follows DNS protocol behavior through c-ares. It does not use the same host-name-service path as dns.lookup(). It also 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.
One module gives you 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 may call dns.lookup() because it wants a socket. A config validator may call dns.resolveSrv() because it wants service records. A platform health check may call resolve4() because it wants to ask a specific resolver for A records. All of those are "DNS" in a loose log message. They are different resolver behaviors inside the process.
The Recursive DNS Path
Most applications do not walk the whole DNS hierarchy themselves. They ask a resolver near them, and that resolver may already have the answer cached.
The usual path looks like this -
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 performs the follow-up work needed to find an answer. Your laptop may use a router, ISP resolver, corporate resolver, cloud VPC resolver, or public resolver. A container may inherit resolver settings from the host or runtime. A Kubernetes pod may use cluster DNS first. The application usually sees one configured resolver address and sends 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 owns 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 that zone.
For api.example.com, an uncached lookup roughly follows this path -
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. The useful working model is simpler - each step either 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 have an old answer. Another resolver may fetch the new answer. A third resolver may apply 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. That is why a name created moments after a failed lookup may still fail from the same network path. The resolver may still be 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 nameservers that own 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 useful 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. A server failure points at resolver path or authoritative server behavior.
CNAME handling is worth keeping in view. 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 one request and one response usually fit. Larger responses, truncation, zone-transfer cases, and some resolver policies can use TCP. Node application code using resolve*() usually leaves that transport choice to c-ares and resolver behavior. This all 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 use this heavily. From Node, it still looks like "the resolver answered." From an operations view, that answer may have gone through local policy first.
That is why comparing production against a public resolver can mislead you. 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 guardrails too. A resolver can stop after too many CNAME hops. It can reject malformed responses. It can retry another authoritative server when one fails. It can fall back to TCP after a truncated UDP answer. It can return a cached failure when upstream servers are unreachable and local policy allows serving that result. These behaviors sit outside Node, but they shape the errors and latency your process sees.
Latency can come from many places in this path. A cold recursive lookup may need several network round trips before Node gets one address candidate. A warm resolver cache can answer in one local round trip. A hosts-file answer through dns.lookup() can complete without DNS network traffic. If the first outbound request after startup is slow, DNS cache warmth is one possible reason. Connection pool warmup and TCP/TLS setup are also possible, so measure phases separately.
The DNS hierarchy also explains partial outages. If the authoritative nameservers for example.com are unhealthy, names below that zone can fail while unrelated domains work. If one resolver network has trouble reaching a TLD path, domains under that suffix can look broken from that resolver and fine elsewhere. If only one record type is misconfigured, A can work while AAAA fails. The useful question is always the smallest 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 meet 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 bigger architecture topic belongs later. At this level, an SRV record is a DNS record whose data includes a port and target name. 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, not identity proof. Certificate validation, HTTP host routing, proxy headers, and application identity all live at 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 and no MX records. A name can have TXT records and no address records. A name can be delegated correctly and still lack the 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;
}
}That wrapper records the actual DNS question. During an incident, "TXT lookup for this name returned this code" is much more useful than "lookup failed."
TTLs, Caches, and Stale Answers
DNS bugs often feel inconsistent because caches sit in several places.
The authoritative nameserver has the current zone data. A recursive resolver may have cached answers. The operating system may cache names. A runtime, library, or connection pool may keep resolved addresses or open sockets. A cloud load balancer may update DNS before every client has dropped old connections. Those are separate sources 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.
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 may use a cached old address. Another process may ask a resolver that already fetched the new answer. A long-lived HTTP client may keep an existing TCP connection to the old address and perform no lookup for the 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.
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. That forwarder may point at the host or cluster resolver. The recursive resolver seen by the process may be several hops away from the authoritative nameserver. The Node API still reports one async result.
When debugging, pin the question -
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 too many things. The name may be wrong. The record type may be missing. The recursive resolver may be stale. The OS resolver may be using a hosts file. The application may be reusing an old connection and doing no lookup at all.
Node's DNS module is an API over lower resolver behavior. Application-level answer caching belongs to your code or a library. Ordinary dns.resolve*() calls ask through c-ares and the configured resolver path. Ordinary dns.lookup() calls ask the OS resolver path. Caching may happen below Node. If your service needs a process-local cache with custom expiration, that cache needs to honor TTLs intentionally.
Long-lived clients create another kind of stale behavior by avoiding lookups. An HTTP agent can keep a socket open. A database pool can keep connections open. A gRPC channel can hold subchannels. While those connections stay alive, the client can keep talking to old addresses after DNS has changed. That is connection reuse, not DNS caching. From the outside, it can still look like stale DNS because new lookups would choose 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. That is why infrastructure changes often need connection draining as well as DNS changes.
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 can repeat the same cached answer until TTL expiration. Trying a numeric address skips the resolver and tests the next layer. Trying another resolver tests resolver-specific cache and policy. Those are different experiments. 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 owns that lifetime.
const addresses = await dns.resolve4('api.example.com');
setTimeout(() => connectTo(addresses[0]), 60_000);That code keeps the address for a minute regardless of the DNS TTL. That may be fine. It may also be a bug. The resolver has no way to pull that value back out of your variable.
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);
});Under the covers, Node calls getaddrinfo() through libuv. getaddrinfo() is the system API that converts a node name and service hints into address structures. For Node's use here, the output is candidate IP addresses and address-family data. The OS resolver owns the policy - hosts file, search suffixes, address-family availability, 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.
The rough call path looks like 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 can fire. Existing sockets can read. Promises can settle. The lookup callback arrives later. The trade-off is worker-pool pressure. A burst of slow OS lookups can sit beside file-system and crypto work in the same finite pool.
for (const host of hosts) {
dns.lookup(host, err => {
if (err) console.error(host, err.code);
});
}That loop lets JavaScript continue while callbacks are pending. It can still create lower-level pressure. If the OS resolver is slow, those lookup jobs sit in the pool. File-system work, crypto work, and other pool users may slow down depending on the process and UV_THREADPOOL_SIZE.
That pool interaction is one reason high-throughput clients sometimes add a lookup cache or a custom resolver strategy. That decision adds responsibility. Once the application caches DNS answers, it owns expiration, negative caching behavior, address-family ordering, and retry policy. A weak cache can keep dead addresses longer than the recursive resolver would have. A cache that ignores failed lookups can hammer a resolver during an outage.
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 shape. 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);
});That call asks configured DNS servers for AAAA records. If the hosts file contains an IPv6 mapping for example.com, resolve6() still asks DNS. If DNS has no AAAA record, the call reports the result for that record type.
The c-ares path looks like this -
JavaScript dns.resolve4()
-> Node dns binding
-> c-ares channel
-> DNS query socket
-> configured nameserver
-> callback on the event loopc-ares owns the DNS query state. It tracks outstanding queries, server choices, retries, timeouts, and response parsing. libuv watches the sockets that c-ares wants to read or write. When a DNS response arrives, c-ares parses it and Node converts the record data into JavaScript return values.
There is no getaddrinfo() call in that path. There is no OS hosts-file lookup in that path. 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.
Many bugs live 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. Same nameserver address, different client behavior.
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 belongs to network policy around DNS query traffic, not 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);
});That changes the servers used by resolve*() calls in the current process context. The OS resolver used by dns.lookup() keeps its own configuration. /etc/resolv.conf, Windows resolver settings, and other host policy outside Node's c-ares state stay 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);
});That instance keeps its own server list for c-ares queries. It stays separate from dns.lookup() and from the global resolver's server list.
Per-instance resolvers are useful for 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. If both answer but disagree, compare TTLs and authority. If both fail while lookup() 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 connection on that host. It follows local host policy. It can see hosts-file entries. It gives you address-family data ready for a socket.
Use dns.resolve*() when you need DNS records as DNS records. MX checks, TXT verification, SRV reads, TTL inspection, and "what does this resolver say for AAAA" all belong there.
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);
});That callback receives one selected address. If the name has ten address records across two families, this output still shows one address. For debugging, use { all: true } and record the whole candidate list. For connection setup, one selected address may be enough if 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);
});That is reverse lookup plus service-name mapping through system facilities. It is useful for diagnostics. Peer validation and application authentication live elsewhere.
lookup() also supports address-family options -
dns.lookup('example.com', { family: 6 }, (err, address) => {
console.log(address);
});That asks for an IPv6 result. With { all: true }, it returns all addresses selected by the OS lookup path and Node ordering rules -
dns.lookup('example.com', { all: true }, (err, addresses) => {
console.log(addresses);
});The objects look like { address: '...', family: 4 } or { address: '...', family: 6 }. That is close to what the later connection attempt 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')
]);That code asks two DNS questions. Socket attempt order is a later connection-policy decision. Fallback policy, local routing, and Happy Eyeballs behavior come after DNS. For this chapter, stop 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.
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 later candidates. 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 try IPv4. Some surface the first failure. Higher-level clients 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.
For debugging, { all: true } is your friend -
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 connect fails, check route, listener, firewall, or transport behavior. If IPv4 is present but ignored by the client, 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.
Use the narrowest control that fits -
dns.lookup(host, { all: true, order: 'ipv6first' }, cb);That call states its own policy. The rest of the process keeps its default. Tests are easier to read too, because the ordering dependency sits next to the assertion.
Happy Eyeballs, the connection strategy that races or staggers address-family attempts, belongs to the full request-path chapter. The DNS part ends at candidate addresses and order. The socket part decides what to try and when.
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. A name can have 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 appear too. ESERVFAIL points at a server failure response. ETIMEOUT points at a query timeout in the DNS-query path. ECONNREFUSED can appear when the configured DNS server rejects the query transport. Keep resolver path, name, type, and server path 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 appears in lookup() and disappears from resolve4(). A custom c-ares server affects the resolver instance and leaves lookup() alone. A corporate resolver may answer internal names that a public resolver cannot answer. A search suffix may make a short hostname work through the OS path while 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 similarly at a larger scope. A name can resolve inside a VPC and fail from a laptop. A public resolver can return no answer while the cloud resolver returns private addresses. That is resolver scope. 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);That tests 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.
The final address-family check is simple - log every answer, including the one the client 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 be present while IPv6 routing is broken. IPv4 may work while an IPv6-first path fails early. DNS produced candidates. The socket path still has to prove each candidate.
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);
});That script checks OS lookup and A-record DNS query behavior. Add resolve6() when address-family behavior is part of the bug. Add a Resolver instance when the resolver server IP is important. Add numeric net.connect() only after you have candidate addresses.
A good DNS incident flow is -
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 prevents a TCP failure from being mislabeled as DNS. It prevents a hosts-file override from being mistaken for authoritative DNS. It prevents an IPv6 route problem from being hidden 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);
});
};That wrapper measures the OS lookup path only. TCP connect time sits below it. If lookup finishes quickly and the request still stalls, move down to the socket path. If lookup consumes most of the request budget, stay with resolver config, cache state, and thread-pool pressure.
Names end at addresses. After that, control returns to the socket primitives - address family, socket address, route lookup, kernel socket state, and the connection lifecycle that TCP owns.