Skip to content
Start with 100 free verification credits
Qualisend
All articles
Engineering / June 6, 2026

How to validate an email address in Node.js

5 minutes read

Qualisend team
A Node.js code window validating an email through syntax, DNS, and SMTP layers

Validating an email address in Node.js is three jobs wearing one name. Most tutorials show you a regex and stop, which checks spelling and calls it validation. Real validation is layered: a cheap syntax check, a DNS lookup, and an SMTP mailbox probe — and Node gives you the first two out of the box. This guide builds each layer with working code and shows where a verification API takes over.

The short answer#

Use a permissive regex for syntax, node:dns/promises for the MX lookup, and a verification API for the SMTP mailbox check — in that order, cheapest first, short-circuiting as soon as one is decisive. Don't try to open SMTP connections from your app server: port 25 is blocked on most hosts, and even where it isn't, the check depends on sending-IP reputation and greylisting handling you don't want to build.

Layer 1: syntax#

Regex belongs here and nowhere else. Keep it permissive — the goal is to catch fat-finger errors at the input, not to reimplement RFC 5322 (which doesn't help anyway):

const SYNTAX = /^[^\s@"]+(?:\.[^\s@"]+)*@[^\s@.]+(?:\.[^\s@.]+)+$/;

export function isValidSyntax(email) {
  return typeof email === "string" && email.length <= 320 && SYNTAX.test(email);
}

A pass here means "worth checking", not "valid". Every following layer assumes the syntax is already sane.

Layer 2: can the domain receive mail?#

This is where Node earns its keep. The built-in node:dns/promises module resolves MX records with no dependencies, and a domain with no mail route can't accept mail for anyone — so this one lookup eliminates dead domains, misspelled company names, and made-up TLDs:

import { resolveMx } from "node:dns/promises";

export async function hasMailRoute(domain) {
  try {
    const mx = await resolveMx(domain);
    return mx.length > 0;
  } catch {
    // ENOTFOUND / ENODATA → domain doesn't exist or publishes no MX
    return false;
  }
}
await hasMailRoute("gmail.com");            // true
await hasMailRoute("company-that-folded.com"); // false

Some domains accept mail on an A record with no MX (implicit MX). If you want to honour that edge case, fall back to dns.resolve4 when resolveMx is empty — but for the overwhelming majority of real addresses, an MX check is the right filter.

Layer 3: does the mailbox actually exist?#

Layers 1 and 2 can only rule an address out. Confirming a mailbox means the SMTP delivery conversation — RCPT TO, read the reply, disconnect before sending anything. In principle you can do this with the net module. In practice you shouldn't: most cloud providers block outbound port 25, the answer depends on the reputation of the IP you connect from, and servers greylist and rate-limit unfamiliar senders. This is the layer worth delegating.

Qualisend's POST /verify runs the full pipeline and returns a verdict. The local checks come back immediately with the SMTP probe queued:

const BASE = "https://app.qualisend.com/api/v1";

export async function verify(email) {
  const res = await fetch(`${BASE}/verify`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.QUALISEND_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ email }),
  });
  if (!res.ok) throw new Error(`Qualisend responded ${res.status}`);
  return res.json();
}
{
  "job_id": "6f1c2e0a-9b3d-4a1e-8c77-1a2b3c4d5e6f",
  "probe_queued": true,
  "result": {
    "email": "jane@example.com",
    "status": "deliverable",
    "score": 95,
    "sub_flags": { "role": false, "disposable": false, "free": false, "catch_all": false },
    "did_you_mean": null,
    "smtp": "pending",
    "reason": null
  }
}

For live signup validation, the immediate result is usually enough to act on — reject undeliverable, offer the did_you_mean correction, flag disposable. When you need the SMTP-confirmed verdict, poll the job until the probe finishes:

export async function verifyAndWait(email, { tries = 10, delayMs = 1500 } = {}) {
  const { job_id } = await verify(email);
  for (let i = 0; i < tries; i++) {
    const res = await fetch(`${BASE}/jobs/${job_id}?include=results`, {
      headers: { Authorization: `Bearer ${process.env.QUALISEND_API_KEY}` },
    });
    const job = await res.json();
    if (job.status === "completed") return job.results[0]; // { status, score, reason, ... }
    await new Promise((r) => setTimeout(r, delayMs));
  }
  return null; // still processing — treat as unknown, retry later
}

Two things trip up the naive fetch above once it hits real traffic. First, a slow upstream shouldn't hang your Node process — wrap the request in an AbortController timeout so it fails fast instead of blocking the event loop. Second, a 429 (rate limited) or a 5xx is transient and worth retrying, while a 4xx like 422 means the payload is wrong and retrying won't help — so separate the two:

export async function verify(email, { timeoutMs = 4000 } = {}) {
  const ctrl = new AbortController();
  const timer = setTimeout(() => ctrl.abort(), timeoutMs);
  try {
    const res = await fetch(`${BASE}/verify`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.QUALISEND_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ email }),
      signal: ctrl.signal,
    });
    if (res.status === 429 || res.status >= 500) {
      throw Object.assign(new Error(`retryable ${res.status}`), { retryable: true });
    }
    if (!res.ok) throw new Error(`Qualisend responded ${res.status}`);
    return res.json();
  } finally {
    clearTimeout(timer);
  }
}

Keep the API key in process.env, never in the bundle — this call belongs on the server side of your Node app, not in anything you ship to the browser.

For list cleaning you're verifying thousands of addresses, not one at a time. Don't loop with await (serial and slow) or fire them all at once (you'll trip the rate limit). Send a bounded number of concurrent requests with Promise.allSettled so one rejected address doesn't sink the whole batch:

export async function verifyMany(emails, { concurrency = 5 } = {}) {
  const out = [];
  for (let i = 0; i < emails.length; i += concurrency) {
    const slice = emails.slice(i, i + concurrency);
    const settled = await Promise.allSettled(slice.map((e) => verify(e)));
    out.push(...settled); // each: { status: "fulfilled", value } | { status: "rejected", reason }
  }
  return out;
}

Collect the rejected entries, back off, and retry only those — the fulfilled verdicts are already done. This same throttled pattern keeps you under the plan's rate limit without any extra dependency.

Putting the layers together#

Cheapest first, stop as soon as you have an answer:

export async function validateEmail(email) {
  if (!isValidSyntax(email)) return { status: "undeliverable", reason: "invalid_email" };
  const domain = email.slice(email.lastIndexOf("@") + 1);
  if (!(await hasMailRoute(domain))) return { status: "undeliverable", reason: "invalid_domain" };
  const { result } = await verify(email);
  return result; // deliverable | risky | undeliverable | unknown, with reason + sub_flags
}

The two local layers cost nothing and catch most junk instantly; the API layer runs only on addresses worth the network round-trip. That ordering is the whole trick — see how email verification works for why each stage sits where it does.

Frequently asked questions#

Is a library like validator.js or email-validator enough?#

Those libraries do layer 1 — syntax — well, and they're a fine replacement for a hand-rolled regex. But they are syntax checkers: they don't resolve MX records or confirm a mailbox, so a validator.isEmail() pass still means "looks like an email", not "will deliver". Pair them with the DNS and SMTP layers above.

Can I check if an email exists from Node without an API?#

Partly. node:dns/promises confirms the domain accepts mail, which rules out dead domains for free. Confirming the mailbox means an SMTP probe, which you can attempt with the net module but shouldn't run from your app server — port 25 is widely blocked and the result depends on your IP's reputation. That's the layer a verification service exists to handle.

Should I validate emails synchronously at signup?#

Do the instant layers synchronously — syntax and MX are fast enough to block the request and give the user immediate feedback. Treat the SMTP result as the slower answer: act on the immediate local verdict at signup, and use the full SMTP-confirmed result for list cleaning. The serverless signup guide shows the pattern end to end.

Should I cache verify results to avoid re-checking the same address?#

Yes. Each verify call is a network round-trip and a spent credit, so don't repeat it for an address you just checked. Key a short-lived cache by the normalised email — lowercase and trim first — and reuse the verdict for a few minutes. One caveat: don't cache unknown or still-pending results, because those are exactly the ones worth retrying once the SMTP probe finishes. A plain Map is enough for a single Node process; reach for Redis only when you run more than one instance and need them to share the cache.


Ready to add the SMTP layer? The free plan includes 100 credits that run the complete pipeline, and the API reference has the full /verify and /jobs endpoints with copy-paste examples in seven languages.

Your reputation, protected.

Clean your first list in minutes. 100 free credits, no card required.

Get started