Skip to main content
DNS Checker(beta)
10 min read

DNS Queries in Node.js: dns.lookup vs dns.resolve Explained

Ishan Karunaratne

Ishan Karunaratne

Software Architect & Infrastructure Engineer

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/hosts or 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()
EngineOS (getaddrinfo)c-ares (DNS protocol)
BlockingBlocks a thread pool threadFully async
Record typesA and AAAA onlyAll types
Respects /etc/hostsYesNo
Works in containersSometimes surprisesPredictable
Used by HTTP clientsYes (default)No (opt-in)
Return formatSingle addressArray 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?.

Frequently Asked Questions

This guide was researched and structured by the author based on hands-on DNS implementation experience, with AI assistance for drafting and code examples.

About the Author

Ishan Karunaratne
Ishan Karunaratne

Software Architect & Infrastructure Engineer

US Army veteran with a B.S. in Information Technology, CompTIA A+, Network+, and Security+ certified. 20+ years building and securing web infrastructure.

B.S. Information Technology — Online SystemsCompTIA A+ (2009)CompTIA Network+ (2009)CompTIA Security+ (2009)US Army Veteran — Operation Iraqi Freedom

Share this article

Related Articles

DNS Lookups in PHP: dns_get_record, gethostbyname, and Beyond

Everything you need for DNS lookups in PHP — from quick gethostbyname() calls to full dns_get_record() queries, checkdnsrr() validation, reverse DNS, and real-world email verification patterns.

Build a DNS Resolver from Scratch in PHP

Implement the DNS protocol in PHP — construct binary query packets with pack(), send raw UDP over sockets to port 53, and parse responses with unpack(). Pure PHP, no extensions required beyond sockets.

Build a DNS Resolver from Scratch in Node.js

Implement the DNS protocol in JavaScript — construct binary query packets with Buffer, send raw UDP to port 53 with dgram, and parse the responses. No dependencies, just Node.js built-ins.

DNS Lookups in Python: Complete Guide with dnspython

Everything you need for DNS lookups in Python — from quick socket.getaddrinfo() calls to full-featured queries with dnspython. Covers all record types, custom nameservers, reverse DNS, async queries, and real-world patterns.