Node.js ships with two completely different approaches to DNS resolution in its standard library, and most tutorials either conflate the two or only cover one. The difference is not academic — choosing the wrong one can bottleneck your application under load, silently bypass your container's DNS configuration, or fail to resolve record types you need.
I wrote this guide after running into the thread pool bottleneck myself while building the backend for DNS Inspector. This article covers both approaches with production-ready examples, every resolve method, the Promises API, custom resolvers, error handling, TypeScript types, and real-world patterns. If you want to understand how DNS queries work at the protocol level before diving into Node.js specifics, start with How DNS Queries Work.
The Critical Distinction: dns.lookup() vs dns.resolve()
This is the single most important thing to understand about DNS in Node.js. The two functions share a name pattern but work in fundamentally different ways.
dns.lookup()
dns.lookup() uses the operating system's name resolution mechanism. Under the hood, it calls getaddrinfo() from libc, which means it runs on the libuv thread pool. Every call blocks one of the pool's threads until the OS returns a result.
Key characteristics:
- Runs on the libuv thread pool — blocks a thread for the duration of the lookup
- Respects
/etc/hosts,nsswitch.conf, mDNS, and any other OS-level name resolution configuration - Returns A/AAAA records only (IP addresses)
- This is what
http.get(),fetch(), and virtually every Node.js HTTP client uses internally - Default thread pool size is 4 — if all 4 threads are busy with DNS lookups, every other DNS call (and all file I/O) queues behind them
dns.resolve()
dns.resolve() makes actual DNS protocol queries using the c-ares library. It is fully asynchronous and does not touch the thread pool.
Key characteristics:
- Uses c-ares to send DNS packets directly — fully async, non-blocking
- Supports all record types (A, AAAA, MX, TXT, NS, SOA, CNAME, SRV, CAA, PTR)
- Does NOT respect
/etc/hostsor OS-level name resolution - More predictable behavior in containers and Docker (no surprises from host-level config)
- Returns arrays of results (since a domain can have multiple records)
Side-by-Side Comparison
import dns from "node:dns";
// Uses the OS resolver (blocks a thread pool thread)
dns.lookup("example.com", (err, address) => {
console.log(address); // "93.184.216.34"
});
// Makes a real DNS query (async, non-blocking)
dns.resolve4("example.com", (err, addresses) => {
console.log(addresses); // ["93.184.216.34"]
});
dns.lookup() | dns.resolve() | |
|---|---|---|
| Engine | OS (getaddrinfo) | c-ares (DNS protocol) |
| Blocking | Blocks a thread pool thread | Fully async |
| Record types | A and AAAA only | All types |
| Respects /etc/hosts | Yes | No |
| Works in containers | Sometimes surprises | Predictable |
| Used by HTTP clients | Yes (default) | No (opt-in) |
| Return format | Single address | Array of addresses |
In practice, use dns.lookup() when you need to behave exactly like the OS (matching what browsers and curl do), and use dns.resolve*() for everything else — especially in high-throughput services.
The Promises API
The callback-based API works, but modern Node.js code should use the Promises API from node:dns/promises. It exposes the same methods but returns promises instead of accepting callbacks.
import dns from "node:dns/promises";
// resolve4 returns a promise
const addresses = await dns.resolve4("example.com");
console.log(addresses); // ["93.184.216.34"]
// lookup also has a promise version
const { address, family } = await dns.lookup("example.com");
console.log(address); // "93.184.216.34"
console.log(family); // 4
All the examples in the rest of this article use the Promises API. If you are stuck on an older codebase that requires callbacks, every method has an equivalent in the node:dns import — just pass a callback as the last argument instead of awaiting the result.
All Resolve Methods
The dns module has a dedicated method for each record type. Here is every one of them with its return type and a usage example.
resolve4(hostname) — A Records
Returns IPv4 addresses as an array of strings.
const addresses = await dns.resolve4("example.com");
// ["93.184.216.34"]
// With TTL information
const detailed = await dns.resolve4("example.com", { ttl: true });
// [{ address: "93.184.216.34", ttl: 3600 }]
resolve6(hostname) — AAAA Records
Returns IPv6 addresses as an array of strings.
const addresses = await dns.resolve6("example.com");
// ["2606:2800:21f:cb07:6820:80da:af6b:8b2c"]
resolveMx(hostname) — MX Records
Returns mail exchange records with priority and exchange server.
const mx = await dns.resolveMx("gmail.com");
// [
// { priority: 5, exchange: "gmail-smtp-in.l.google.com" },
// { priority: 10, exchange: "alt1.gmail-smtp-in.l.google.com" },
// { priority: 20, exchange: "alt2.gmail-smtp-in.l.google.com" },
// ...
// ]
resolveTxt(hostname) — TXT Records
Returns TXT records. Note the return type is string[][] — an array of arrays, because each TXT record can be split into multiple strings (a single TXT record has a 255-byte chunk limit in the DNS protocol).
const txt = await dns.resolveTxt("example.com");
// [["v=spf1 -all"], ["some-verification=abc123"]]
// Each inner array is one TXT record's chunks
// Join them if you need the full value:
const flat = txt.map((chunks) => chunks.join(""));
resolveNs(hostname) — NS Records
Returns nameserver hostnames as an array of strings.
const ns = await dns.resolveNs("example.com");
// ["a.iana-servers.net", "b.iana-servers.net"]
resolveSoa(hostname) — SOA Record
Returns the Start of Authority record as a single object (there is only one per zone).
const soa = await dns.resolveSoa("example.com");
// {
// nsname: "ns.icann.org",
// hostmaster: "noc.dns.icann.org",
// serial: 2024012345,
// refresh: 7200,
// retry: 3600,
// expire: 1209600,
// minttl: 3600
// }
resolveCname(hostname) — CNAME Records
Returns canonical names as an array of strings.
const cnames = await dns.resolveCname("www.example.com");
// ["example.com"]
resolveSrv(hostname) — SRV Records
Returns service records with priority, weight, port, and target name.
const srv = await dns.resolveSrv("_sip._tcp.example.com");
// [{ priority: 10, weight: 60, port: 5060, name: "sip.example.com" }]
resolveCaa(hostname) — CAA Records
Returns Certificate Authority Authorization records.
const caa = await dns.resolveCaa("example.com");
// [{ critical: 0, issue: "letsencrypt.org" }]
resolvePtr(hostname) — PTR Records (Reverse DNS)
Returns pointer records. Typically used for reverse DNS on in-addr.arpa names.
const ptr = await dns.resolvePtr("34.216.184.93.in-addr.arpa");
// ["example.com"]
reverse(ip) — Reverse DNS Lookup
A convenience wrapper that performs a reverse DNS lookup on an IP address without requiring the in-addr.arpa formatting.
const hostnames = await dns.reverse("8.8.8.8");
// ["dns.google"]
resolve(hostname, rrtype) — Generic Resolve
The generic method that accepts a record type string. Useful when the record type is dynamic.
const records = await dns.resolve("example.com", "MX");
// Same result as resolveMx()
resolveAny(hostname) — All Records
Returns all available record types in a single query. Note that many DNS servers do not support ANY queries and will return an empty result or refuse.
const all = await dns.resolveAny("example.com");
// Mixed array of record objects with a "type" field
Custom Resolver
By default, dns.resolve*() methods use the system's configured DNS servers (from /etc/resolv.conf on Linux/macOS). If you need to query a specific nameserver — an authoritative server, an internal DNS, or a public resolver like Google or Cloudflare — use the Resolver class.
import { Resolver } from "node:dns/promises";
const resolver = new Resolver();
resolver.setServers(["8.8.8.8", "8.8.4.4"]);
const addresses = await resolver.resolve4("example.com");
console.log(addresses);
// Check which servers are configured
console.log(resolver.getServers()); // ["8.8.8.8", "8.8.4.4"]
The Resolver instance has all the same resolve* methods as the dns/promises module. Creating a custom resolver does not affect the global resolver — other parts of your application (including HTTP clients) continue using the system DNS servers.
This is particularly useful for:
- Querying authoritative nameservers directly to bypass caching and check if a record has been updated. The Propagation Checker uses exactly this pattern — creating a resolver for each nameserver location to compare answers.
- Using a specific public resolver when your system resolver is unreliable or slow.
- Internal DNS queries in split-horizon environments where your internal DNS server serves different records than the public one.
Error Handling
DNS queries can fail for many reasons. The error object includes a code property with a string constant you can match against.
import dns from "node:dns/promises";
try {
await dns.resolve4("nonexistent.example.com");
} catch (err) {
switch (err.code) {
case "ENOTFOUND":
console.log("Domain does not exist (NXDOMAIN)");
break;
case "ENODATA":
console.log("No records of this type for the domain");
break;
case "ETIMEOUT":
console.log("DNS query timed out");
break;
case "ESERVFAIL":
console.log("DNS server returned a failure");
break;
case "EREFUSED":
console.log("DNS query was refused by the server");
break;
case "EBADRESP":
console.log("Malformed DNS response");
break;
case "ECONNREFUSED":
console.log("Could not connect to DNS server");
break;
default:
console.log(`DNS error: ${err.code} — ${err.message}`);
}
}
A few notes on these codes:
- ENOTFOUND maps to the DNS NXDOMAIN response code — the domain genuinely does not exist.
- ENODATA means the domain exists but has no records of the requested type (e.g., querying AAAA on a domain that only has an A record).
- ESERVFAIL maps to SERVFAIL — the DNS server tried to resolve the query but something went wrong (often a DNSSEC validation failure or unreachable authoritative server).
- ETIMEOUT is common when querying unreachable nameservers or when network conditions are poor.
The Timeout Problem
Here is something that catches many developers off guard: the Node.js dns module has no built-in timeout option. If a DNS server is slow or unresponsive, your query can hang for a long time (c-ares has its own internal retry/timeout logic, but you cannot configure it per-query).
The workaround is to race the DNS promise against a timeout and cancel the resolver if the timeout wins:
import { Resolver } from "node:dns/promises";
async function resolveWithTimeout(hostname, rdtype = "A", timeoutMs = 5000) {
const resolver = new Resolver();
resolver.setServers(["8.8.8.8"]);
const lookup = resolver.resolve(hostname, rdtype);
const timeout = new Promise((_, reject) =>
setTimeout(() => {
resolver.cancel(); // Cancels all outstanding queries on this resolver
reject(new Error(`DNS query timed out after ${timeoutMs}ms`));
}, timeoutMs)
);
return Promise.race([lookup, timeout]);
}
// Usage
try {
const records = await resolveWithTimeout("example.com", "MX", 3000);
console.log(records);
} catch (err) {
console.error(err.message);
}
The key is resolver.cancel() — it aborts all pending queries on that resolver instance. This is why you should create a new Resolver for each timed operation rather than reusing one, since cancelling a shared resolver would kill all in-flight queries.
TypeScript Types
If you are using TypeScript, the @types/node package includes full type definitions for the dns module. Here are the types you will work with most:
import dns from "node:dns/promises";
// Built-in types from @types/node — no need to define your own
// Shown here for reference
interface MxRecord {
priority: number;
exchange: string;
}
interface SoaRecord {
nsname: string;
hostmaster: string;
serial: number;
refresh: number;
retry: number;
expire: number;
minttl: number;
}
interface SrvRecord {
priority: number;
weight: number;
port: number;
name: string;
}
// These are the actual return types from @types/node
const mx: dns.MxRecord[] = await dns.resolveMx("gmail.com");
const soa: dns.SoaRecord = await dns.resolveSoa("example.com");
const srv: dns.SrvRecord[] = await dns.resolveSrv("_sip._tcp.example.com");
// resolve4 with TTL option changes the return type
const basic: string[] = await dns.resolve4("example.com");
const withTtl: dns.RecordWithTtl[] = await dns.resolve4("example.com", {
ttl: true,
});
// RecordWithTtl = { address: string; ttl: number }
// lookup returns a different shape
const result: { address: string; family: number } =
await dns.lookup("example.com");
The type system correctly narrows the return type based on which method you call and which options you pass. For resolve4 and resolve6, passing { ttl: true } changes the return type from string[] to RecordWithTtl[].
Thread Pool Considerations
If your application uses dns.lookup() heavily (which happens implicitly through HTTP clients), you can hit the thread pool bottleneck. The default UV thread pool size is 4, and it is shared across all dns.lookup() calls, file system operations, and certain crypto operations.
Option 1: Increase the Thread Pool
Set the UV_THREADPOOL_SIZE environment variable before the process starts. It must be set before Node.js initializes — setting it at runtime has no effect.
UV_THREADPOOL_SIZE=64 node server.js
The maximum is 1024. For HTTP-heavy services making many outbound requests, 64 or 128 is a reasonable starting point.
Option 2: Use dns.resolve Instead of dns.lookup
You can configure Node.js HTTP agents to use dns.resolve4() / dns.resolve6() instead of dns.lookup(), bypassing the thread pool entirely:
import http from "node:http";
import dns from "node:dns";
// Tell the global HTTP agent to use dns.resolve instead of dns.lookup
const agent = new http.Agent({
lookup: (hostname, options, callback) => {
dns.resolve4(hostname, (err, addresses) => {
if (err) return callback(err, "", 4);
// Return the first address
callback(null, addresses[0], 4);
});
},
});
// Use the agent in requests
http.get("http://example.com", { agent }, (res) => {
// ...
});
The tradeoff: this skips /etc/hosts and OS-level name resolution. If your application relies on hostfile entries (common in Docker Compose for service discovery), this approach will break those lookups. In a containerized production environment where all service discovery happens through DNS, it is usually the right choice.
Option 3: Use a DNS Cache
For applications that repeatedly resolve the same hostnames, caching DNS results in memory avoids both the thread pool and redundant network queries:
import dns from "node:dns/promises";
const cache = new Map();
async function cachedResolve(hostname, ttlMs = 60_000) {
const cached = cache.get(hostname);
if (cached && Date.now() - cached.time < ttlMs) {
return cached.addresses;
}
const addresses = await dns.resolve4(hostname);
cache.set(hostname, { addresses, time: Date.now() });
return addresses;
}
Real-World Example: Service Health Monitor
Here is a practical example that ties together custom resolvers, error handling, and timeouts — a DNS-based health monitor for your infrastructure:
import { Resolver } from "node:dns/promises";
const SERVICES = [
{ name: "API", hostname: "api.example.com", type: "A" },
{ name: "CDN", hostname: "cdn.example.com", type: "A" },
{ name: "Mail", hostname: "example.com", type: "MX" },
{ name: "Auth", hostname: "_dmarc.example.com", type: "TXT" },
];
async function checkHealth() {
const resolver = new Resolver();
resolver.setServers(["8.8.8.8"]);
const results = [];
for (const service of SERVICES) {
const start = performance.now();
try {
const records = await resolver.resolve(service.hostname, service.type);
const ms = (performance.now() - start).toFixed(1);
results.push({
name: service.name,
status: "ok",
records: records.length,
latency: `${ms}ms`,
});
} catch (err) {
const ms = (performance.now() - start).toFixed(1);
results.push({
name: service.name,
status: "error",
code: err.code,
latency: `${ms}ms`,
});
}
}
return results;
}
// Run every 30 seconds
setInterval(async () => {
const results = await checkHealth();
console.table(results);
}, 30_000);
This pattern is how I started building what eventually became the Propagation Checker — querying the same domain from multiple resolver locations and comparing the results to detect propagation delays.
Under the Hood
Everything in this article uses Node.js's built-in DNS abstractions. But if you want to understand what dns.resolve does at the protocol level — how binary DNS packets are constructed, sent over UDP, and parsed — see Build a DNS Resolver from Scratch in Node.js. That guide implements the raw DNS protocol with nothing but dgram sockets and a Buffer.
For other DNS debugging approaches beyond Node.js, the dig command remains the fastest way to inspect records from the command line, and the DNS Inspector provides a web-based interface for querying all record types across multiple resolvers.
For more DNS fundamentals, see How DNS Queries Work and Understanding DNS Record Types. To debug DNS propagation issues, check the Propagation Checker or read What Is DNS Propagation?.
