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/promisesorrequire('node:dns').promises. resolveMx()always performs a DNS query on the network. Unlikedns.lookup(), it doesn’t use the operating system’sgetaddrinfoand 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.code | Meaning | Treat as |
|---|---|---|
ETIMEOUT | No answer from DNS servers in time | Unknown, retry later |
ESERVFAIL | DNS server returned a general failure (often a broken authoritative server or DNSSEC problem) | Unknown |
ECONNREFUSED | Couldn’t contact the DNS servers | Unknown; check your resolver |
EREFUSED | DNS server refused the query | Unknown |
ECANCELLED | You 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.
Free email verifier: syntax, MX, disposable and role-based addresses, typos. Paste up to 100 at once.