Validating Email Addresses in JavaScript and Node.js, With Code
To validate an email address in JavaScript, check the format with validator.isEmail (or <input type="email"> in the browser), then on the server call dns.promises.resolveMx to confirm the domain accepts mail, handling null MX and the A-record fallback. Only an SMTP conversation can confirm the mailbox, and that needs outbound port 25.
Everything below was run on Node.js 22 on 1 October 2026. Each snippet is copied from the file we actually executed.
The three layers, and what each one proves
Syntax proves the string is shaped like an address, DNS proves the domain can receive mail, and SMTP is the only layer that says anything about the mailbox.
| Layer | Where it can run | Proves | Typical cost |
|---|---|---|---|
| Syntax | Browser or server | The string is a well-formed address | Microseconds |
| DNS (MX) | Server, or browser via DNS-over-HTTPS | The domain exists and accepts mail | Milliseconds to hundreds of milliseconds |
| SMTP RCPT | Server with outbound port 25 | The server accepts or rejects that mailbox | Seconds |
Most validation libraries stop at the first row. A typo of a real domain and a made-up mailbox both pass it.
Layer 1: syntax
In the browser, <input type="email"> already applies the WHATWG pattern; on the server, use validator.isEmail rather than a hand-written regex.
The browser's built-in check costs nothing:
<input type="email" name="email" required autocomplete="email">
form.checkValidity() or the :invalid CSS selector then gives you the result. It uses the pattern defined in the HTML standard, which is sensible but accepts john..doe@example.com and john@example.
On the server, never trust the browser's check, and do not write your own regex. We tested five common email regexes and the best got 15 of 23 cases right, while one took nearly five seconds to reject a 33-character string. validator (version 13.15.35 in our test) got every one of those cases right and rejected a 50,000-character input in a few microseconds:
npm install validator
import validator from "validator";
validator.isEmail("o'brien@example.ie"); // true
validator.isEmail("john..doe@example.com"); // false
validator.isEmail("john@example"); // false
Its defaults, per the validator.js README, include require_tld: true and allow_utf8_local_part: true. If your downstream systems cannot handle non-ASCII addresses, set allow_utf8_local_part: false deliberately rather than discovering it later.
Layer 2: does the domain accept mail?
Use resolveMx from node:dns/promises with a short timeout, and handle three outcomes most examples skip: null MX, no MX with an A record, and DNS failure.
Here is the function we use for examples on this blog:
import { Resolver } from "node:dns/promises";
const resolver = new Resolver({ timeout: 3000, tries: 2 });
export async function checkMailDomain(domain) {
try {
const records = await resolver.resolveMx(domain);
if (records.length === 1 && records[0].exchange === "") {
return { status: "invalid", reason: "null MX: domain declares it accepts no mail" };
}
const hosts = records.sort((a, b) => a.priority - b.priority).map((r) => r.exchange);
return { status: "ok", hosts };
} catch (err) {
if (err.code === "ENOTFOUND") return { status: "invalid", reason: "domain does not exist" };
if (err.code !== "ENODATA") return { status: "unknown", reason: `DNS error ${err.code}` };
}
const hasAddress = await Promise.any([
resolver.resolve4(domain),
resolver.resolve6(domain),
]).then(() => true, () => false);
return hasAddress
? { status: "ok", hosts: [domain], note: "no MX, using implicit MX (A/AAAA)" }
: { status: "invalid", reason: "no MX and no address records" };
}
What it returned for real domains:
| Domain | Result | Why |
|---|---|---|
gmail.com |
ok, gmail-smtp-in.l.google.com first |
Five MX records, sorted by priority |
example.com |
invalid, null MX | Publishes 0 . |
nonexistent-domain-sv-test-9a8b7c.com |
invalid, does not exist | ENOTFOUND (NXDOMAIN) |
gmial.com |
ok, implicit MX | No MX record, but has an A record |
github.io |
ok, implicit MX | No MX record, but has an A record |
Null MX is real, and Node reports it quietly
RFC 7505 lets a domain say it accepts no mail by publishing a single MX record with priority 0 and an exchange of .. example.com and example.org both do. Node does not throw for this: resolveMx returns [{ exchange: '', priority: 0 }]. Code that only checks records.length > 0 will treat these domains as valid.
The implicit MX question
RFC 5321 section 5.1 says that if a domain has no MX records, "the address is treated as if it was associated with an implicit MX RR, with a preference of 0, pointing to that host". So a domain with only an A record can, in principle, receive mail.
That is why gmial.com, a typo of gmail.com, comes back as ok above. Strictly correct, and probably not what you want on a signup form. Our approach is to accept it at the DNS layer and flag it separately; catching typos like gmial.com is its own problem with its own fix.
Error handling matters more than the happy path
The Node.js DNS documentation lists over twenty error codes. Only two of them mean "this domain does not receive mail":
ENOTFOUND: the domain does not exist.ENODATA: the domain exists but has no records of that type, which is when you try A and AAAA.
ETIMEOUT, ESERVFAIL, ECONNREFUSED and the rest mean your DNS lookup failed, not that the address is bad. Return unknown and let the user through, or retry later. Rejecting a real customer because your resolver hiccupped is the worst outcome in this whole pipeline.
Use Resolver, not dns.lookup
The Node docs are explicit that dns.lookup() is "a synchronous call to getaddrinfo(3) that runs on libuv's threadpool", while the resolve* functions "always perform a DNS query on the network" asynchronously and do not use the threadpool. A Resolver instance also lets you set timeout and tries; the default is four tries per name server, which can turn one slow domain into a long wait on a signup request.
How long it takes
We timed checkMailDomain on 20 domains from a residential US connection using the system resolver. The median was 14 ms. Most well-known mail domains answered in under 30 ms; the slowest, gmx.com, took 449 ms, and bbc.co.uk took 137 ms. Raw numbers are in our research notes. These are one connection on one day, so treat them as an order of magnitude, but they are why a 3-second timeout is generous rather than tight.
Checking MX from the browser
Browsers have no DNS API, but DNS-over-HTTPS JSON endpoints are CORS-enabled, so fetch works.
When we tested on 1 October 2026, both cloudflare-dns.com/dns-query and dns.google/resolve returned access-control-allow-origin: *. Cloudflare's JSON API documentation requires the accept: application/dns-json header.
const DOH = "https://cloudflare-dns.com/dns-query";
const MX = 15;
const NXDOMAIN = 3;
export async function domainLooksMailable(domain, { timeoutMs = 2500 } = {}) {
const url = `${DOH}?name=${encodeURIComponent(domain)}&type=MX`;
try {
const res = await fetch(url, {
headers: { accept: "application/dns-json" },
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) return "unknown";
const body = await res.json();
if (body.Status === NXDOMAIN) return "no";
const mx = (body.Answer ?? []).filter((a) => a.type === MX).map((a) => a.data);
if (mx.length === 1 && mx[0].endsWith(" .")) return "no";
return mx.length > 0 ? "yes" : "unknown";
} catch {
return "unknown";
}
}
It returned yes for gmail.com and outlook.com, no for example.com (null MX) and a non-existent domain, and unknown for gmial.com (no MX record). Use it for an inline hint such as "that domain does not seem to receive email", never as the only check, and re-run the server-side version on submit. Every domain a user types goes to a third party, so say so in your privacy notice.
Layer 3: asking the mail server
An SMTP probe connects to the recipient's MX on port 25, sends EHLO, MAIL FROM and RCPT TO, reads the reply and quits before any message is sent.
This is a minimal version, with no dependencies, that we ran against Gmail:
import net from "node:net";
export function probeRcpt(mxHost, rcpt, { heloName, mailFrom, timeoutMs = 10000 }) {
const steps = [`EHLO ${heloName}`, `MAIL FROM:<${mailFrom}>`, `RCPT TO:<${rcpt}>`];
const replies = [];
return new Promise((resolve) => {
const socket = net.connect(25, mxHost);
let buffer = "";
const finish = (result) => {
if (replies.length > 0 && socket.writable) socket.end("QUIT\r\n");
else socket.destroy();
resolve({ ...result, replies });
};
socket.setTimeout(timeoutMs, () => finish({ status: "unknown", reason: "timeout (is outbound port 25 blocked?)" }));
socket.on("error", (err) => finish({ status: "unknown", reason: err.code }));
socket.on("data", (chunk) => {
buffer += chunk.toString("utf8");
const lines = buffer.split("\r\n");
buffer = lines.pop();
for (const line of lines) {
if (line[3] === "-") continue;
replies.push(line);
const code = Number(line.slice(0, 3));
const step = replies.length - 1;
if (step === 3) {
if (code === 250) return finish({ status: "accepted" });
if (/^55[0-3][ -]5\.1\.\d/.test(line)) return finish({ status: "rejected" });
return finish({ status: "unknown", reason: `RCPT reply ${code}` });
}
if (code >= 400) return finish({ status: "unknown", reason: `reply ${code} before RCPT` });
if (step < 3) socket.write(steps[step] + "\r\n");
}
});
});
}
Against a made-up Gmail address, from a residential connection where port 25 is open, the replies were:
220 mx.google.com ESMTP ... - gsmtp
250 SMTPUTF8
250 2.1.0 OK ... - gsmtp
550 5.1.1 https://support.google.com/mail/?p=NoSuchUser ... - gsmtp
Pointed at an unroutable address it returned unknown with the timeout reason after three seconds, which is exactly what a blocked port 25 looks like: a silent hang, not a refusal.
Three design decisions in that code are the important part:
- Only a
5.1.xenhanced status counts as rejected. A550 5.7.1is a policy block against you, often because your IP is on a blocklist, and says nothing about the mailbox. Treating every 550 as "does not exist" is how verifiers end up deleting real customers. accepteddoes not mean the mailbox exists. Catch-all domains accept every address, so you need to probe a random address at the same domain first. Some large providers also accept at RCPT and bounce later.- Anything else is
unknown. Greylisting (450/451), timeouts and odd replies are not evidence either way. Our rule at SimpleVerifier is that anything unconfirmed is returned as risky, never valid, and that rule is worth copying.
Why you probably should not run this in production yourself
The code is the easy part. You need a server whose host allows outbound port 25, an IP with clean reputation and matching reverse DNS, a real heloName and mailFrom on a domain you control, rate limiting per recipient domain, and catch-all detection. Probing at volume from a fresh IP gets that IP blocked by the very providers you are probing. This is the reason email verification is sold as a service.
Putting the first two layers together
For a signup form, syntax plus DNS on the server catches malformed input and dead domains at almost no cost; SMTP is a separate decision.
import { domainToASCII } from "node:url";
import validator from "validator";
import { checkMailDomain } from "./mx.mjs";
export async function validateEmail(raw) {
const email = String(raw).trim();
if (!validator.isEmail(email)) return { status: "invalid", reason: "syntax" };
const domain = domainToASCII(email.slice(email.lastIndexOf("@") + 1).toLowerCase());
const dns = await checkMailDomain(domain);
if (dns.status !== "ok") return { status: dns.status, reason: dns.reason };
return { status: "deliverable-domain", mx: dns.hosts[0] };
}
domainToASCII matters: validator accepts user@münchen.de, and converting to xn--mnchen-3ya.de before the lookup gives you a consistent answer from any resolver. Results from our run:
| Input | Result |
|---|---|
jane@gmail.com |
deliverable-domain |
jane@@gmail.com |
invalid, syntax |
jane@example.com |
invalid, null MX |
jane@nonexistent-domain-sv-test-9a8b7c.com |
invalid, domain does not exist |
user@münchen.de |
deliverable-domain (implicit MX) |
Note the honest status name. deliverable-domain is not valid, because nothing here has asked about the mailbox.
The practical takeaway
- Use
<input type="email">in the browser andvalidator.isEmailon the server. Do not maintain your own regex. - Look up MX with a
Resolverthat has a short timeout. Handle null MX (emptyexchange) and the A-record fallback explicitly. - Treat DNS errors other than
ENOTFOUNDandENODATAas unknown, and let the user through. - If you add an SMTP probe, only
5.1.xrejections mean "no such mailbox", and acceptance on a catch-all domain means nothing. - For the same checks in another language, see validating email in Python. To see what a full check returns for one address, try the free email verifier.
Common questions
How do I validate an email address in JavaScript?
In the browser, use input type=email or validator.js isEmail for the format. On the server, follow that with a DNS lookup using dns.promises.resolveMx from node:dns to confirm the domain can receive mail. Neither step proves the mailbox exists; that needs an SMTP check from a network that allows outbound port 25.
Can I check MX records from the browser?
Not with built-in browser APIs, but DNS-over-HTTPS services return JSON and send CORS headers. Cloudflare's endpoint at cloudflare-dns.com/dns-query answered with access-control-allow-origin: * when we tested it, so a fetch call works. You are sending every domain your users type to a third party, so mention it in your privacy notice.
What does an empty exchange from resolveMx mean?
It is a null MX record, defined in RFC 7505, published as '0 .'. The domain is declaring that it never accepts email. example.com and example.org both publish one, and Node returns it as a single record with an empty exchange string. Treat the address as invalid.
Should a domain with no MX record be rejected?
Strictly, no: RFC 5321 says to fall back to the domain's A or AAAA record as an implicit MX. In practice most real mail domains publish MX records, and A-only domains on a signup form are often typos or parked domains, so many products flag them for review rather than accept them silently.
Verify unlimited addresses for $29.99/month
Real SMTP mailbox checks. No credits, no per-email fees.
Get Started