ItsBingMyBusiness

Developer Guides

How to Do an MX Lookup in Node.js (dns.promises.resolveMx)

To do an MX lookup in Node.js, call resolveMx() from the built-in node:dns/promises module. It returns an array of { priority, exchange } objects, which you sort by priority, lowest first. That’s the whole happy path. The rest of this guide covers what production code has to handle: domains with no MX, null MX records, error codes, timeouts, caching, and internationalized domains. All code runs on Node 22 with no dependencies.

The basic MX lookup

// mx.mjs  (Node 22+)  Usage: node mx.mjs gmail.com outlook.com
import { resolveMx } from 'node:dns/promises';

for (const domain of process.argv.slice(2)) {
  try {
    const records = await resolveMx(domain);
    records.sort((a, b) => a.priority - b.priority);
    console.log(domain);
    for (const { priority, exchange } of records) {
      console.log(`  ${String(priority).padStart(3)}  ${exchange || '(null MX)'}`);
    }
  } catch (err) {
    console.log(`${domain}: ${err.code}`);
  }
}

Running it against a few domains:

$ node mx.mjs gmail.com example.com github.io no-such-domain-zzzz-12345.com
gmail.com
    5  gmail-smtp-in.l.google.com
   10  alt1.gmail-smtp-in.l.google.com
   20  alt2.gmail-smtp-in.l.google.com
   30  alt3.gmail-smtp-in.l.google.com
   40  alt4.gmail-smtp-in.l.google.com
example.com
    0  (null MX)
github.io: ENODATA
no-such-domain-zzzz-12345.com: ENOTFOUND

Those four domains show every case you need to handle, and the rest of this post walks through each one.

A few facts from the Node.js DNS documentation worth knowing up front:

  • The promises API is available as node:dns/promises or require('node:dns').promises.
  • resolveMx() always performs a DNS query on the network. Unlike dns.lookup(), it doesn’t use the operating system’s getaddrinfo and ignores /etc/hosts.
  • Records come back in no guaranteed order, so always sort by priority. Lower values are preferred.

If you want a refresher on what these records mean, see what an MX record is.

Reading the results: four cases

1. MX records found

The normal case. gmail.com returns five hosts. A sending server tries the lowest priority first and falls back to the others. For verification purposes, “has at least one usable MX” is the signal you want.

2. Null MX: the domain refuses all mail

example.com returns a single record with priority: 0 and an empty exchange. That’s a null MX, defined in RFC 7505: one MX record with preference 0 and the root (.) as the exchange, which Node reports as an empty string. It means “this domain does not accept email”. Treat any address at such a domain as invalid. Don’t mistake it for a lookup glitch.

3. ENODATA: the domain exists but has no MX

github.io exists but publishes no MX records, so resolveMx() rejects with ENODATA. This is not automatically “no mail”. RFC 5321 section 5.1 says that when no MX records exist, the domain itself is treated as an implicit MX with preference 0. If the domain has an A or AAAA record, mail servers will try to deliver there. Your code should check for those before giving up.

The same section also says that if MX records exist, address records for the domain must not be used for mail. So only fall back when the MX answer is empty.

4. ENOTFOUND: the domain doesn’t exist

ENOTFOUND from a resolve call means the DNS answered “no such domain” (NXDOMAIN). An address at a nonexistent domain can’t receive mail. One caution from the Node docs: for dns.lookup(), ENOTFOUND can also mean other failures, such as running out of file descriptors. With resolveMx() it’s a direct NXDOMAIN answer, which is one more reason to use the resolve functions here.

Error codes that mean “unknown”, not “invalid”

The DNS module exports its error codes as constants (dns.NODATA, dns.NOTFOUND, and so on), whose values are the strings you see on err.code. Apart from ENODATA and ENOTFOUND, the ones you’ll meet most often are:

err.codeMeaningTreat as
ETIMEOUTNo answer from DNS servers in timeUnknown, retry later
ESERVFAILDNS server returned a general failure (often a broken authoritative server or DNSSEC problem)Unknown
ECONNREFUSEDCouldn’t contact the DNS serversUnknown; check your resolver
EREFUSEDDNS server refused the queryUnknown
ECANCELLEDYou called resolver.cancel()Unknown

The important design rule: never map a resolver failure to “invalid email”. If your DNS server hiccups during a signup, a user with a perfectly good address shouldn’t be turned away.

A production-ready MX lookup module

This module adds what the basic version lacks: a dedicated resolver with a timeout, implicit MX fallback, null MX detection, Unicode domain handling, and a small cache that also deduplicates concurrent lookups for the same domain.

// mx-lookup.mjs  (Node 22+, no dependencies)
import { Resolver } from 'node:dns/promises';
import { domainToASCII } from 'node:url';

const resolver = new Resolver({ timeout: 2500, tries: 2 });
// Optional: pin public resolvers instead of the OS defaults.
// resolver.setServers(['1.1.1.1', '8.8.8.8']);

const cache = new Map(); // domain -> { expires, promise }
const TTL_OK_MS = 60 * 60 * 1000;   // cache good answers for an hour
const TTL_ERR_MS = 60 * 1000;       // cache transient failures briefly

/**
 * Resolves the mail route for a domain.
 * kind: 'mx' | 'implicit' | 'null_mx' | 'no_mail' | 'nxdomain' | 'error'
 */
export function lookupMail(rawDomain) {
  const domain = domainToASCII(rawDomain.trim().replace(/\.$/, '').toLowerCase());
  if (!domain) return Promise.resolve({ kind: 'error', code: 'BAD_DOMAIN', hosts: [] });

  const hit = cache.get(domain);
  if (hit && hit.expires > Date.now()) return hit.promise;

  const promise = resolveRoute(domain);
  cache.set(domain, { expires: Date.now() + TTL_OK_MS, promise });
  promise.then((r) => {
    if (r.kind === 'error') cache.set(domain, { expires: Date.now() + TTL_ERR_MS, promise });
  });
  return promise;
}

async function resolveRoute(domain) {
  let records;
  try {
    records = await resolver.resolveMx(domain);
  } catch (err) {
    if (err.code === 'ENOTFOUND') return { kind: 'nxdomain', hosts: [] };
    if (err.code !== 'ENODATA') return { kind: 'error', code: err.code, hosts: [] };
    records = [];
  }

  if (records.length === 1 && records[0].exchange === '') {
    return { kind: 'null_mx', hosts: [] }; // RFC 7505
  }

  if (records.length > 0) {
    records.sort((a, b) => a.priority - b.priority);
    return { kind: 'mx', hosts: records.map((r) => r.exchange), records };
  }

  // RFC 5321 section 5.1: no MX at all means the domain itself is the implicit MX.
  const [v4, v6] = await Promise.allSettled([resolver.resolve4(domain), resolver.resolve6(domain)]);
  const hasAddress = [v4, v6].some((r) => r.status === 'fulfilled' && r.value.length > 0);
  if (hasAddress) return { kind: 'implicit', hosts: [domain] };

  const failed = [v4, v6].find(
    (r) => r.status === 'rejected' && !['ENODATA', 'ENOTFOUND'].includes(r.reason.code),
  );
  return failed
    ? { kind: 'error', code: failed.reason.code, hosts: [] }
    : { kind: 'no_mail', hosts: [] };
}

// Demo: node mx-lookup.mjs gmail.com example.com github.io bücher.de
const domains = process.argv.slice(2);
const results = await Promise.all(domains.map(lookupMail));
domains.forEach((d, i) => console.log(d.padEnd(20), JSON.stringify({ kind: results[i].kind, hosts: results[i].hosts })));

Output:

gmail.com            {"kind":"mx","hosts":["gmail-smtp-in.l.google.com", ...]}
example.com          {"kind":"null_mx","hosts":[]}
github.io            {"kind":"implicit","hosts":["github.io"]}
bücher.de            {"kind":"implicit","hosts":["xn--bcher-kva.de"]}

When you import it as a module, delete the demo lines at the bottom. Here’s why each piece is there.

Timeouts and retries with the Resolver class

The Resolver constructor accepts timeout (milliseconds per query attempt) and tries (attempts per name server, default 4). With the defaults, a dead name server can stall a request for a long time. For a signup form, a couple of seconds per attempt and two tries is a reasonable start. Each Resolver instance has its own server list, so calling setServers() on it doesn’t affect the rest of your app.

The Node docs note one subtlety of setServers(): if the first server returns NXDOMAIN, Node doesn’t ask the next one. Fallback servers are only used when earlier ones time out or fail in other ways. That’s correct DNS behavior, but it surprises people who expect “try all servers until one says yes”.

Caching

resolveMx() doesn’t return TTLs (only resolve4() and resolve6() accept a ttl option), so the module uses a fixed cache lifetime. An hour is reasonable for MX data. Failures are cached for a minute so a domain with a broken DNS server doesn’t get hammered, but recovers quickly.

Caching the promise rather than the result has a useful side effect: if a bulk job checks 500 addresses at gmail.com at once, only one DNS query goes out.

In a long-running process, put a size limit on the cache or use an LRU. This sketch grows without bound.

Unicode domains

DNS works in ASCII. An internationalized domain like bücher.de exists in DNS as its punycode form, xn--bcher-kva.de. url.domainToASCII() converts it and returns an empty string for invalid input. In testing, passing the raw Unicode name straight to resolveMx() returned ENODATA instead of a real answer, so convert first. For the full story on non-ASCII addresses, see internationalized email addresses.

Should you resolve the MX hosts too?

RFC 5321 says each MX exchange name must resolve to at least one A or AAAA record. You can check that with resolver.resolve4(host) for each exchange, and it does catch a few broken configurations, such as MX records pointing at a hostname that no longer exists. It also multiplies your DNS traffic. For most verification use cases, a non-null MX answer is a strong enough signal.

What an MX lookup can’t tell you

An MX lookup confirms the domain is set up to receive mail. It doesn’t confirm that a particular mailbox exists. Finding that out means connecting to the MX host on port 25 and issuing RCPT TO, which is unreliable for reasons covered in how SMTP email verification works, and outbound port 25 is blocked by default on many cloud hosts (we explain the AWS case in why EC2 blocks port 25).

To look up a domain’s MX records by hand, see how to check MX records. If you’d rather not run DNS lookups yourself, the API on this site returns the MX hosts along with syntax, disposable, role, and typo checks in one call:

curl -s "https://itsbingmybusiness.com/tools/email-verifier/api/v1/verify?email=someone@example.com" \
  -H "X-API-Key: $VERIFIER_API_KEY"

The response’s mx field lists the hosts and checks.mx tells you whether the domain can receive mail. Free keys are on the API documentation page, or try an address in the free email verifier. For a broader comparison of approaches, read how to validate an email address in JavaScript.

FAQ

How do I get MX records in Node.js?

Import resolveMx from node:dns/promises and await it with the domain name. It resolves to an array of objects with priority and exchange. Sort by priority ascending, because the order isn’t guaranteed.

What’s the difference between dns.lookup and dns.resolveMx?

dns.lookup() asks the operating system to resolve a hostname to an IP address, the same way most programs do, and it respects /etc/hosts. resolveMx() and the other resolve functions send DNS queries over the network directly and can fetch specific record types like MX. For MX records, you need a resolve function.

Why does resolveMx return ENODATA for a domain that has a website?

The domain exists but publishes no MX records. Under RFC 5321, mail for such a domain goes to the domain’s own A or AAAA address, so check for those before marking the address invalid. Many web-only domains still don’t run a mail server, so an implicit MX is a weaker signal than a real MX record.

How do I detect a null MX record in Node.js?

resolveMx() returns a single record with priority 0 and an empty exchange string. That’s the RFC 7505 null MX, meaning the domain accepts no email. Treat addresses at that domain as undeliverable.

Check your list before you send

Free email verifier: syntax, MX, disposable and role-based addresses, typos. Paste up to 100 at once.

Verify emails free