Email Validation at the Edge: Building a Cloudflare Worker for Real-Time Verification
Your Origin Server Shouldn’t Be Doing This
Every signup request hits your origin. Your backend parses the body, calls a validation API, waits 200-400ms for the response, then sends back a result. Multiply that by a few thousand signups during a Product Hunt launch and your Node or Rails process is spending most of its time waiting on network I/O it didn’t need to handle.
Cloudflare Workers run at the edge across 330+ cities worldwide. Cold starts under 5ms. V8 isolates instead of containers. Put email validation there, and the request never reaches your origin. The user gets a response from the data center closest to them, not from your us-east-1 server 180ms away.
This tutorial builds a complete Cloudflare Worker that validates emails in under 50ms (cached) or under 400ms (uncached). You’ll have it deployed in 15 minutes.
The Architecture
Four actors. One request path.
- Client sends an email to your Worker endpoint.
- Worker checks Cloudflare KV for a cached result.
- If no cache hit, Worker calls the email validation API and stores the result.
- Worker returns the validation response to the client.
Your origin server never sees the request. Not on cache hits, not on misses. The Worker handles everything.
Why does this matter for latency? A user in Tokyo hitting your Virginia backend adds 150-200ms of round-trip time before your server even starts processing. That same user hitting a Cloudflare Worker gets routed to a Tokyo data center. The validation API call still goes out, but the response travels a shorter path back. For cached results, the entire round trip stays within Cloudflare’s network. Under 50ms total.
Project Setup
You’ll need the Wrangler CLI and a Cloudflare account (the free tier works for this).
npm create cloudflare@latest -- email-validator --category=hello-world
cd email-validator
Configure wrangler.toml with KV namespace bindings and rate limiting:
name = "email-validator"
main = "src/index.js"
compatibility_date = "2026-05-01"
[vars]
TRUEMAIL_API_URL = "https://api.truemail.io/v1/verify"
# KV namespace for caching validation results
[[kv_namespaces]]
binding = "EMAIL_CACHE"
id = "your-kv-namespace-id"
preview_id = "your-preview-kv-namespace-id"
Create the KV namespace:
npx wrangler kv namespace create EMAIL_CACHE
npx wrangler kv namespace create EMAIL_CACHE --env preview
Wrangler prints the namespace IDs. Paste them into wrangler.toml. For your API key, use a secret instead of a plaintext variable:
npx wrangler secret put TRUEMAIL_API_KEY
This stores the key encrypted. It won’t appear in your wrangler.toml or version control. Ever.
The Complete Worker
Here’s the full implementation. Read through it, then we’ll break down each piece.
// src/index.js
export default {
async fetch(request, env, ctx) {
// CORS preflight
if (request.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders() });
}
if (request.method !== "POST") {
return jsonResponse({ error: "POST required" }, 405);
}
// Rate limiting by IP
const clientIP = request.headers.get("CF-Connecting-IP");
const rateLimitKey = `rate:${clientIP}`;
const currentCount = parseInt(await env.EMAIL_CACHE.get(rateLimitKey)) || 0;
if (currentCount >= 20) {
return jsonResponse({ error: "Rate limit exceeded. Max 20 requests per minute." }, 429);
}
await env.EMAIL_CACHE.put(rateLimitKey, String(currentCount + 1), {
expirationTtl: 60,
});
// Parse and validate input
let body;
try {
body = await request.json();
} catch {
return jsonResponse({ error: "Invalid JSON body" }, 400);
}
const email = (body.email || "").trim().toLowerCase();
if (!email || !email.includes("@")) {
return jsonResponse({ error: "Provide a valid email field" }, 400);
}
// Check KV cache first
const cacheKey = `validation:${email}`;
const cached = await env.EMAIL_CACHE.get(cacheKey, { type: "json" });
if (cached) {
return jsonResponse({
...cached,
cached: true,
validated_at: cached.validated_at,
});
}
// Call validation API
const apiResponse = await fetch(env.TRUEMAIL_API_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${env.TRUEMAIL_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email, checks: ["syntax", "mx", "smtp"] }),
});
if (!apiResponse.ok) {
return jsonResponse(
{ error: "Validation service unavailable", status: "unknown" },
502
);
}
const result = await apiResponse.json();
// Cache with status-dependent TTL
const ttl = result.status === "deliverable" ? 86400 : 3600;
const cachePayload = {
email: result.email,
status: result.status,
disposable: result.disposable || false,
catch_all: result.catch_all || false,
mx_found: result.mx_found || false,
validated_at: new Date().toISOString(),
};
await env.EMAIL_CACHE.put(cacheKey, JSON.stringify(cachePayload), {
expirationTtl: ttl,
});
return jsonResponse({ ...cachePayload, cached: false });
},
};
function jsonResponse(data, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: {
...corsHeaders(),
"Content-Type": "application/json",
},
});
}
function corsHeaders() {
return {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};
}
That’s the whole thing. About 100 lines of working code.
Breaking Down the Key Decisions
Cache TTL Strategy
Deliverable results get cached for 24 hours (86,400 seconds). Undeliverable results get 1 hour (3,600 seconds).
Why the asymmetry? An email that’s valid right now will almost certainly be valid tomorrow. But an undeliverable address could be a temporary DNS issue, a full mailbox, or a typo the user just fixed and re-registered. One hour gives them a window to retry without getting stale “invalid” results.
This same caching pattern shows up in other frameworks too. The principle doesn’t change at the edge. Only the storage layer does (KV instead of Redis).
Rate Limiting with KV
The Worker limits each IP to 20 requests per minute. Simple counter in KV with a 60-second TTL. When the key expires, the counter resets.
Is this production-grade rate limiting? No. KV is eventually consistent, so two simultaneous requests from the same IP could both read a count of 19 and both increment to 20. You’ll occasionally let 21 or 22 through. KV also enforces a maximum of 1 write per second per key, so rapid-fire requests from the same IP may hit that limit before your own rate limiter kicks in. For email validation, that’s fine. You’re preventing abuse, not building a billing system.
For stricter enforcement, Cloudflare’s Rate Limiting rules (configured in the dashboard or via API) run before your Worker executes. They’re atomic and don’t have the eventual consistency gap. Use those for hard limits. Use the in-Worker approach for soft limits that don’t justify the extra configuration.
Fail-Open on API Errors
When the validation API returns a non-200 response, the Worker returns status: "unknown" with a 502. It doesn’t cache the failure. It doesn’t block the user. Your frontend should treat “unknown” as “let them through and verify later.”
This fail-open pattern matters. A 3-second API timeout during a traffic spike shouldn’t lock every new user out of your signup flow.
Calling the Worker from Your Frontend
Drop this into any signup form. Framework doesn’t matter.
async function validateEmail(email) {
const response = await fetch("https://email-validator.your-subdomain.workers.dev", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
return response.json();
}
// Usage in a form handler
const result = await validateEmail("[email protected]");
if (result.status === "undeliverable") {
showError("That email doesn't look deliverable. Double-check it?");
} else if (result.disposable) {
showError("Please use a non-disposable email address.");
}
If you’re working in Next.js, the Next.js email validation guide covers how to wire this into Server Actions with Zod schemas. The Worker becomes your validation backend instead of calling the API directly from your Next.js server.
Blocking Disposable Emails at the Edge
The Worker already returns disposable: true in the response. But you can go further and reject them outright before they even get a validation API call.
Maintain a small blocklist of the most common disposable email domains in your Worker:
const DISPOSABLE_DOMAINS = new Set([
"guerrillamail.com",
"mailinator.com",
"tempmail.com",
"throwaway.email",
"yopmail.com",
"10minutemail.com",
"trashmail.com",
]);
function isDisposableDomain(email) {
const domain = email.split("@")[1];
return DISPOSABLE_DOMAINS.has(domain);
}
Check this before the KV lookup. If the domain matches, return immediately without burning an API call or a cache slot. Zero latency. Zero cost. You won’t catch all 190,000+ known disposable domains this way, but you’ll block the top offenders before they reach anything downstream.
Deploying and Testing
Deploy with one command:
npx wrangler deploy
Wrangler pushes the Worker to Cloudflare’s network. It’s live globally within seconds. Test it with curl:
curl -X POST https://email-validator.your-subdomain.workers.dev \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]"}'
First request hits the validation API. You’ll see "cached": false in the response. Run it again. Second request pulls from KV. "cached": true and response time drops to single-digit milliseconds.
For local development, wrangler dev starts a local server with KV bindings:
npx wrangler dev
Performance: Edge vs Origin
How much faster is this? Numbers from real-world testing.
The validation API call itself takes 200-400ms regardless of where you call it from. That’s the SMTP handshake time, and it doesn’t compress. What changes is everything around it.
For cached results, an origin-based setup (client to server, server checks Redis, server responds) runs 80-150ms depending on user distance from your server. The Worker serves from KV at the nearest edge location. That’s 5-15ms. For users far from your origin, the difference is 10x.
For uncached results, the improvement is smaller but still real. The Worker calls the API from Cloudflare’s network, which peers directly with most cloud providers. A validation call from a Cloudflare data center to an API hosted on AWS typically runs 20-40ms faster than the same call from a user’s browser. Not dramatic on its own, but combined with the eliminated origin round trip, you’re saving 100-200ms per uncached request.
Where this really shines: geographic diversity. A user in Mumbai, a user in Sao Paulo, and a user in Berlin all get sub-50ms cached responses. With a single-region origin, at least two of them would be waiting 200ms+ just for the network round trip.
When Not to Use This Pattern
This isn’t the right approach for everything.
If you’re validating emails as part of a server-side form submission that already hits your origin (creating a user record, sending a welcome email), adding a Worker in front of it adds complexity without much benefit. Your origin already has the request. Validate there.
If you need webhook-based async validation for batch imports (thousands of emails at once), Workers aren’t the tool. That’s a queue-and-callback pattern, not a request/response one.
Workers make sense for client-side validation before form submission, API gateways that validate emails before routing to microservices, and checkout flows where every millisecond of latency costs conversion. Put it where the speed matters.
The Quick Reference
Your wrangler.toml, one JavaScript file, and three CLI commands. That’s the entire deployment. The Worker handles caching, rate limiting, CORS, and fail-open error handling. Cached results return in under 15ms from the nearest edge location.
Clone it, swap in your API key, deploy. Fifteen minutes to edge-validated emails worldwide.