Email validation in Node.js: libraries, services, and best practices
Your signup form accepts test@@gmial.con without complaint. A week later bounce rate climbs past 5%, your ESP docks your domain reputation, and legitimate mail starts landing in spam. This guide walks through how to structure email validation in a Node.js app: from npm packages for syntax checking to SMTP verification through an external API.
Four levels of email checking
Email validation is not a single operation. It is a pipeline where each stage is more accurate than the last but costs more to run.
1. Syntax. Does an @ exist? Is the domain non-empty? Any spaces? Runs in microseconds. Catches obvious garbage: empty strings, random keyboard mashing, addresses with no domain.
2. MX lookup. Query DNS: if the domain has no MX record, it does not accept mail. Takes 50-200 ms, and you already know there is no point sending there.
3. SMTP handshake. Connect to the mail server, send RCPT TO, analyze the response. The only way to check whether a specific mailbox actually exists.
4. AI scoring and reputation. Detecting disposable addresses, spam traps, catch-all domains. This requires data that no local library has. That is where external services come in.
The npm ecosystem covers the first three levels. The fourth needs an API. Below is working code for each approach.
email-validator: fast syntax checking
The email-validator package is the most popular choice for syntax validation. No dependencies, no network requests, works on the server and in the browser.
npm install email-validatorimport * as EmailValidator from 'email-validator';
EmailValidator.validate('user@example.com'); // true
EmailValidator.validate('user@.com'); // false
EmailValidator.validate('user@@example.com'); // false
EmailValidator.validate('user@example'); // falseUnder the hood it is a regex compatible with the core rules of RFC 5322. The package makes no DNS requests and does not check whether the mailbox exists. Its job is to confirm the string looks like an email address. For the first validation level, that is enough.
Typical use: middleware in Express or Fastify that rejects the request before the data ever reaches business logic.
import * as EmailValidator from 'email-validator';
function validateEmailMiddleware(req, res, next) {
const { email } = req.body;
if (!email || typeof email !== 'string') {
return res.status(400).json({ error: 'Email is required' });
}
const trimmed = email.trim().toLowerCase();
if (!EmailValidator.validate(trimmed)) {
return res.status(400).json({ error: 'Invalid email format' });
}
req.body.email = trimmed;
next();
}
// Usage
app.post('/api/signup', validateEmailMiddleware, signupHandler);deep-email-validator: syntax + MX + SMTP
When format checking is not enough, there is deep-email-validator. It checks syntax, MX records, disposable domain lists, and attempts an SMTP connection in sequence.
npm install deep-email-validatorimport { validate } from 'deep-email-validator';
async function checkEmail(email) {
const result = await validate({
email,
validateRegex: true,
validateMx: true,
validateTypo: true,
validateDisposable: true,
validateSMTP: true,
});
return result;
}
const result = await checkEmail('user@example.com');
console.log(result.valid); // true | false
console.log(result.reason); // "smtp" | "disposable" | "mx" | ...
console.log(result.validators);
// {
// regex: { valid: true },
// typo: { valid: true },
// disposable: { valid: true },
// mx: { valid: true, mxRecords: [...] },
// smtp: { valid: true }
// }Useful for prototypes and internal tooling. In production, though, there are real limitations worth knowing.
SMTP checks are unreliable from shared hosting. Most mail servers block or throttle SMTP connections from cloud provider IP ranges (AWS, GCP, DigitalOcean). If your server runs on one of those, SMTP validation will return false negatives.
The disposable domain list goes stale. The package ships a static list of throwaway mail services. New services appear every week, and between package updates they pass right through.
No data on catch-all domains or spam traps. A catch-all server replies 250 OK to any address. SMTP verification is useless there. Identifying catch-all reliably requires aggregated data from thousands of checks, which a local package does not have.
Manual MX checking with dns.promises
If you do not need everything deep-email-validator does, you can implement MX checking yourself. Node.js ships a dns module with promise support. No external dependencies required.
import dns from 'node:dns/promises';
async function hasMxRecords(email) {
const domain = email.split('@')[1];
if (!domain) return false;
try {
const records = await dns.resolveMx(domain);
return records.length > 0;
} catch {
// ENOTFOUND, ENODATA — domain doesn't exist or has no MX
return false;
}
}
await hasMxRecords('user@gmail.com'); // true
await hasMxRecords('user@fakesite.xyz'); // false (most likely)Pairing email-validator for syntax and dns.resolveMx for MX is a lightweight, controllable second-level filter. No external services, no third-party dependencies. For many projects this is a solid first line of defense.
Validation with Zod and other schema libraries
If the project already uses Zod for input validation, a separate email package may be redundant. Zod checks email format out of the box.
import { z } from 'zod';
const SignupSchema = z.object({
email: z
.string()
.trim()
.toLowerCase()
.email('Invalid email format'),
password: z.string().min(8, 'Minimum 8 characters'),
});
// Express handler
app.post('/api/signup', (req, res) => {
const parsed = SignupSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
errors: parsed.error.flatten().fieldErrors,
});
}
// parsed.data.email is already normalized
createUser(parsed.data);
});The same approach works with Yup, Joi, and ArkType. Schema libraries solve the same problem as email-validator but inside a unified validation system. Use whatever is already in the project rather than adding another dependency.
Where npm packages run out of road
All of these libraries work with what is available locally: string analysis, DNS, an attempted SMTP connection. That filters out 60-70% of bad addresses. The remaining 30-40% pass and land in your database.
Catch-all domains. The server replies 250 OK to any address. SMTP checks are useless here. Roughly 15-20% of corporate domains run catch-all. Determining whether a specific mailbox on such a domain is real requires historical data that no local package carries.
New disposable domains. Static lists go stale in days. A service that launched yesterday passes any list shipped in npm.
Spam traps. An address like xk7q9mz2@gmail.com is syntactically valid, has MX records, and SMTP responds correctly. Sending to it destroys your domain reputation. A regex cannot tell the difference between that address and a real one.
SMTP IP blocks. Cloud providers end up on mail server blocklists. SMTP verification from AWS EC2 or GCP Compute Engine often returns a false rejection. Specialized services use IP rotation and established reputation to work around this.
External API: full verification in one request
Email validation services address these gaps through aggregated data, dedicated SMTP infrastructure, and ML models for risk scoring. A single HTTP request returns a result across all four levels.
Example integration with the uChecker API in Node.js:
const UCHECKER_API = 'https://api.uchecker.net/api/v1/validate/single';
async function validateEmail(email) {
const response = await fetch(UCHECKER_API, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.UCHECKER_API_KEY,
},
body: JSON.stringify({ email }),
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json();
}
const result = await validateEmail('user@example.com');
// {
// result: "deliverable", // deliverable | undeliverable | risky | unknown
// disposable: false,
// catch_all: false,
// reason: null
// }The response is not a binary valid/invalid. You get a deliverability status, a disposable flag, a catch-all flag, and the reason for any rejection. What to do with that information stays your decision.
Express middleware: local check + API verification
A practical pattern: syntax checking rejects obvious garbage instantly, and addresses that pass the first filter go to API validation. Two levels in a single middleware function.
import * as EmailValidator from 'email-validator';
async function emailValidationMiddleware(req, res, next) {
const { email } = req.body;
if (!email || typeof email !== 'string') {
return res.status(400).json({ error: 'Email is required' });
}
const normalized = email.trim().toLowerCase();
// Level 1: syntax (instant)
if (!EmailValidator.validate(normalized)) {
return res.status(400).json({
error: 'Invalid email format',
code: 'INVALID_FORMAT',
});
}
// Levels 2-4: API validation
try {
const apiResult = await fetch(
'https://api.uchecker.net/api/v1/validate/single',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.UCHECKER_API_KEY,
},
body: JSON.stringify({ email: normalized }),
signal: AbortSignal.timeout(5000), // 5-second timeout
}
);
const data = await apiResult.json();
if (data.result === 'undeliverable') {
return res.status(400).json({
error: 'Email address does not exist',
code: 'UNDELIVERABLE',
});
}
if (data.disposable) {
return res.status(400).json({
error: 'Disposable email addresses are not accepted',
code: 'DISPOSABLE',
});
}
// Store validation data for business logic
req.emailValidation = {
email: normalized,
result: data.result,
catchAll: data.catch_all,
disposable: data.disposable,
};
} catch (err) {
// API unavailable — let the address through, check later
console.error('Email validation API error:', err.message);
req.emailValidation = {
email: normalized,
result: 'unchecked',
catchAll: null,
disposable: null,
};
}
req.body.email = normalized;
next();
}
app.post('/api/signup', emailValidationMiddleware, signupHandler);Note AbortSignal.timeout(5000). If the API does not respond within 5 seconds, the middleware lets the address through marked as unchecked. Registration does not break. Those addresses get cleaned up later via bulk validation.
Bulk validation: queue + batch API
Not every address needs to be checked in real time. For cleaning an existing list or deferred validation, the batch approach works well: collect addresses into a queue and send a batch request to the API.
import fs from 'node:fs/promises';
const API_BASE = 'https://api.uchecker.net/api/v1';
const headers = {
'Content-Type': 'application/json',
'x-api-key': process.env.UCHECKER_API_KEY,
};
// 1. Upload a file of email addresses
async function uploadList(filePath) {
const emails = (await fs.readFile(filePath, 'utf-8'))
.split('\n')
.map(e => e.trim())
.filter(Boolean);
const res = await fetch(`${API_BASE}/validate/bulk`, {
method: 'POST',
headers,
body: JSON.stringify({ emails }),
});
const { task_id } = await res.json();
return task_id;
}
// 2. Poll for task completion
async function waitForResult(taskId, interval = 5000) {
while (true) {
const res = await fetch(`${API_BASE}/tasks/${taskId}`, { headers });
const task = await res.json();
if (task.status === 'completed') return task;
if (task.status === 'failed') throw new Error('Task failed');
await new Promise(r => setTimeout(r, interval));
}
}
// 3. Run
const taskId = await uploadList('./emails.txt');
console.log('Task created:', taskId);
const result = await waitForResult(taskId);
console.log('Deliverable:', result.summary.deliverable);
console.log('Undeliverable:', result.summary.undeliverable);
console.log('Risky:', result.summary.risky);For lists over 10,000 addresses, split into batches of 5-10k and handle rate limiting (HTTP 429).
Production best practices
The technical integration is half the work. The other half is the architecture around it.
Normalize before checking. Lowercase the email. For Gmail, strip dots from the local part and drop everything after +. The same person using John.Doe+promo@Gmail.Com and johndoe@gmail.com should not appear twice in your database.
function normalizeEmail(email) {
let [local, domain] = email.trim().toLowerCase().split('@');
if (['gmail.com', 'googlemail.com'].includes(domain)) {
local = local.replace(/\./g, '').split('+')[0];
domain = 'gmail.com';
}
return `${local}@${domain}`;
}Cache results. If a user corrects their address and resubmits the form, there is no reason to spend another API call on the same address. A simple in-memory cache with a 10-minute TTL is plenty for a signup form.
const cache = new Map();
const TTL = 10 * 60 * 1000; // 10 minutes
async function validateWithCache(email) {
const key = normalizeEmail(email);
const cached = cache.get(key);
if (cached && Date.now() - cached.timestamp < TTL) {
return cached.result;
}
const result = await validateEmail(key);
cache.set(key, { result, timestamp: Date.now() });
// Cap cache size
if (cache.size > 1000) {
const oldest = cache.keys().next().value;
cache.delete(oldest);
}
return result;
}Do not block registration when the API is down. Graceful degradation matters more than perfect accuracy. If the validation service is temporarily unavailable, accept the address and queue it for later. Losing a real user is worse than letting one questionable email through.
Store the validation result. Add email_status and email_checked_at columns to your users table. Filter by status at send time. Re-check every 3-6 months since mailboxes close and domains expire.
Rate limit the endpoint. Your validation endpoint is exposed externally. A bot can fire thousands of requests and drain your API balance. Limit to 5-10 requests per minute per IP.
Keep the API key server-side only. Never send it to client-side JavaScript. All requests to the validation service go through your backend. The UCHECKER_API_KEY environment variable must never end up in your bundle.
Comparison of approaches
The right choice depends on context. Here is a summary to help you decide.
| Approach | What it checks | Speed | Limitations |
|---|---|---|---|
| email-validator | Syntax | < 1 ms | Does not check whether the mailbox exists |
| Zod / Joi / Yup | Syntax + schema | < 1 ms | Format only, same limits |
| dns.resolveMx | Domain MX records | 50-200 ms | Does not check individual mailboxes |
| deep-email-validator | Syntax + MX + SMTP | 1-5 sec | SMTP blocked by cloud IP ranges |
| External API | All levels + AI scoring | 1-3 sec | Paid, external dependency |
In practice these approaches stack. Syntax checking on every request, API validation at registration, periodic bulk verification of the whole list. Each level handles a different class of problem.
What to use for your project
Side project or MVP. email-validator or z.string().email(). Fast, free, catches basic typos. At an early stage that is usually enough.
Product with a signup form. Syntax check on the frontend, API validation on the backend. One call per registration costs fractions of a cent but saves real money on ESP fees and reputation maintenance.
Email platform or CRM. Full pipeline: syntax + API at the point of entry, regular bulk validation of the whole database, stored statuses, automatic exclusion of bad addresses from sends.
The general rule: the more each contact in your list is worth, the more validation levels make sense. For a free newsletter, regex may genuinely be enough. For a SaaS where each user is a potential customer worth thousands, API verification pays for itself on day one.
Syntax checking catches 60% of bad addresses at no cost. An API covers the remaining 40%, including catch-all domains, disposable addresses, and spam traps. The question is not which one to pick but how many levels your project actually needs.
Try the validation API on real addresses: sign up at uChecker and get 30 free checks to test your integration.
