How to Build an Email Validation Microservice with Node.js and Redis

hangrydev ·

Your App Shouldn’t Call the Validation API Directly

You’ve got a signup form. On submit, your backend fires a request to an email validation API, waits 200-400ms, and returns the result. Works fine at 50 signups a day. At 5,000? You’re burning API quota on duplicate emails, hammering rate limits during traffic spikes, and one slow upstream response cascades into a timeout your user sees.

The fix is a layer between your app and the validation API. A microservice that caches results, queues batch jobs, and rate-limits outbound calls so your upstream provider doesn’t throttle you. Redis handles the first two. BullMQ handles the third. Fifteen minutes of setup saves you thousands of wasted API calls per month.

This tutorial builds that layer with Node.js, Redis, and BullMQ. By the end, you’ll have three endpoints, a caching layer that cuts API calls by 60-80%, and a queue system for bulk validation.

The Architecture

Your app talks to the microservice. The microservice talks to Redis and the email validation API. Your app never touches the external API directly.

Request flow for single validation:

  1. Client sends POST /validate with an email address.
  2. Microservice checks Redis for a cached result.
  3. Cache hit? Return it. Cache miss? Call the validation API, cache the result, return it.
  4. Redis TTL expires the cache entry after 24-48 hours.

Request flow for batch validation:

  1. Client sends POST /validate/batch with an array of emails.
  2. Microservice creates a BullMQ job, returns a job ID immediately.
  3. Worker processes emails with configurable concurrency, caching each result.
  4. Client polls GET /validate/:jobId for progress and results.

Why separate the paths? Single validation needs sub-500ms response times for signup forms. Batch validation processes thousands of addresses and doesn’t need to block. Mixing them in one synchronous endpoint means your signup form waits while someone’s CSV import chews through 10,000 rows. Keep them apart. The batch vs real-time validation split is a design decision you won’t regret.

Project Setup

mkdir email-validation-service && cd email-validation-service
npm init -y
npm install fastify bullmq ioredis rate-limiter-flexible dotenv

Fastify over Express here. It’s 2-3x faster on benchmarks and the schema validation is built in. If you prefer Express, swap fastify for express and adjust the route syntax. The Redis and queue logic stays identical.

Your .env file:

TRUEMAIL_API_KEY=your_api_key_here
TRUEMAIL_API_URL=https://api.truemail.io/v1/verify
REDIS_URL=redis://localhost:6379
PORT=3001
RATE_LIMIT_PER_MINUTE=60
BATCH_CONCURRENCY=5
CACHE_TTL_VALID=86400
CACHE_TTL_INVALID=3600

Two TTL values. Valid emails get 24 hours (86,400 seconds) because an address that works today almost certainly works tomorrow. Invalid results get 1 hour. Why shorter? A bounced address could be a temporary DNS issue or a full mailbox. Giving it a short window lets retries pick up fixes without serving stale “invalid” results all day.

Redis Caching Layer

The cache alone is worth the microservice. A SaaS app with 10,000 daily signups where 30% of users resubmit forms (typo in name, weak password) burns 3,000 extra API calls per day on emails it already validated. Cache those results and you’re down to 7,000 calls. Real savings.

// src/cache.js
import Redis from "ioredis";

const redis = new Redis(process.env.REDIS_URL);

export async function getCachedResult(email) {
  const key = `ev:${email.toLowerCase()}`;
  const cached = await redis.get(key);
  return cached ? JSON.parse(cached) : null;
}

export async function cacheResult(email, result) {
  const key = `ev:${email.toLowerCase()}`;
  const ttl =
    result.status === "deliverable"
      ? parseInt(process.env.CACHE_TTL_VALID)
      : parseInt(process.env.CACHE_TTL_INVALID);

  await redis.set(key, JSON.stringify(result), "EX", ttl);
}

export { redis };

Lowercase the email before keying. [email protected] and [email protected] are the same mailbox. Without normalization, you’d cache them separately and double your API calls for every user who capitalizes their email.

The ev: prefix keeps validation keys separate from rate-limit keys and job data. Namespacing matters when you’re sharing a Redis instance.

Rate Limiting the Upstream API

Your validation provider has rate limits. MailCop’s API, like most providers, throttles beyond a certain requests-per-second threshold. Hit that limit and you get 429s that cascade into failed validations across your entire app.

The rate-limiter-flexible library uses Redis as a backend, so it works across multiple instances of your microservice. No sticky sessions needed.

// src/rateLimiter.js
import { RateLimiterRedis } from "rate-limiter-flexible";
import { redis } from "./cache.js";

export const apiRateLimiter = new RateLimiterRedis({
  storeClient: redis,
  keyPrefix: "rl:api",
  points: parseInt(process.env.RATE_LIMIT_PER_MINUTE),
  duration: 60,
  blockDuration: 10,
});

export async function waitForRateLimit() {
  try {
    await apiRateLimiter.consume("outbound", 1);
  } catch (rateLimiterRes) {
    const retryAfter = Math.ceil(rateLimiterRes.msBeforeNext / 1000) || 1;
    await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
    return waitForRateLimit();
  }
}

waitForRateLimit() blocks until a slot opens instead of rejecting the request. For rate limiting email validation in production, this means batch jobs slow down gracefully instead of failing. The blockDuration: 10 adds a 10-second cooldown if you exhaust the limit, preventing a thundering herd when the window resets.

The Validation Service

One function that wraps cache lookup, rate limiting, and the API call. Everything else in the microservice calls this.

// src/validator.js
import { getCachedResult, cacheResult } from "./cache.js";
import { waitForRateLimit } from "./rateLimiter.js";

export async function validateEmail(email) {
  const normalized = email.toLowerCase().trim();

  const cached = await getCachedResult(normalized);
  if (cached) {
    return { ...cached, cached: true };
  }

  await waitForRateLimit();

  const response = await fetch(process.env.TRUEMAIL_API_URL, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.TRUEMAIL_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ email: normalized }),
  });

  if (!response.ok) {
    throw new Error(`Validation API returned ${response.status}`);
  }

  const result = await response.json();
  const payload = {
    email: normalized,
    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 cacheResult(normalized, payload);
  return { ...payload, cached: false };
}

Notice the fail pattern. If the API returns a non-200, the function throws. The caller decides what to do with it. Signup endpoints should fail open (accept the email, verify later). Batch jobs should retry. Don’t bake that decision into the validator itself.

BullMQ for Batch Jobs

Single-email validation is synchronous. Batch validation needs a queue. BullMQ gives you persistent jobs, configurable concurrency, automatic retries, and progress tracking. All backed by Redis.

// src/queue.js
import { Queue, Worker } from "bullmq";
import { redis } from "./cache.js";
import { validateEmail } from "./validator.js";

const connection = { host: redis.options.host, port: redis.options.port };

export const validationQueue = new Queue("email-validation", { connection });

export function startWorker() {
  const worker = new Worker(
    "email-validation",
    async (job) => {
      const { emails } = job.data;
      const results = [];

      for (let i = 0; i < emails.length; i++) {
        try {
          const result = await validateEmail(emails[i]);
          results.push(result);
        } catch (err) {
          results.push({
            email: emails[i],
            status: "error",
            error: err.message,
          });
        }

        await job.updateProgress(Math.round(((i + 1) / emails.length) * 100));
      }

      return results;
    },
    {
      connection,
      concurrency: parseInt(process.env.BATCH_CONCURRENCY),
    }
  );

  worker.on("failed", (job, err) => {
    console.error(`Job ${job.id} failed: ${err.message}`);
  });

  return worker;
}

BATCH_CONCURRENCY=5 means five jobs process in parallel. Each job processes its emails sequentially (because the rate limiter gates outbound calls). Five concurrent jobs with a rate limit of 60 per minute gives you roughly 12 validations per second per job, 60 across all jobs. Tune these two numbers together.

Why not validate emails within a single job in parallel? Because the rate limiter is the bottleneck, not the processing. Firing 50 concurrent API calls just means 45 of them wait for rate limit slots. Sequential per job, parallel across jobs keeps the throughput predictable.

API Endpoints

Three routes. That’s the whole surface area.

// src/server.js
import Fastify from "fastify";
import { validateEmail } from "./validator.js";
import { validationQueue, startWorker } from "./queue.js";
import { redis } from "./cache.js";
import "dotenv/config";

const app = Fastify({ logger: true });

// Single email validation
app.post("/validate", async (request, reply) => {
  const { email } = request.body || {};
  if (!email || !email.includes("@")) {
    return reply.status(400).send({ error: "Valid email required" });
  }

  try {
    const result = await validateEmail(email);
    return result;
  } catch (err) {
    return reply.status(502).send({
      email,
      status: "unknown",
      error: "Validation service unavailable",
    });
  }
});

// Batch validation (async via queue)
app.post("/validate/batch", async (request, reply) => {
  const { emails } = request.body || {};
  if (!Array.isArray(emails) || emails.length === 0) {
    return reply.status(400).send({ error: "Provide an emails array" });
  }

  if (emails.length > 10000) {
    return reply.status(400).send({ error: "Max 10,000 emails per batch" });
  }

  const job = await validationQueue.add("validate-batch", { emails });
  return reply.status(202).send({
    jobId: job.id,
    status: "queued",
    total: emails.length,
    poll: `/validate/${job.id}`,
  });
});

// Job status and results
app.get("/validate/:jobId", async (request, reply) => {
  const job = await validationQueue.getJob(request.params.jobId);
  if (!job) {
    return reply.status(404).send({ error: "Job not found" });
  }

  const state = await job.getState();
  const progress = job.progress || 0;

  if (state === "completed") {
    return { jobId: job.id, status: "completed", results: job.returnvalue };
  }

  return { jobId: job.id, status: state, progress };
});

// Health check
app.get("/health", async () => {
  const redisPing = await redis.ping();
  return { status: "ok", redis: redisPing === "PONG" ? "connected" : "down" };
});

const start = async () => {
  const worker = startWorker();
  await app.listen({ port: parseInt(process.env.PORT), host: "0.0.0.0" });

  const shutdown = async () => {
    app.log.info("Shutting down...");
    await worker.close();
    await validationQueue.close();
    await redis.quit();
    await app.close();
    process.exit(0);
  };

  process.on("SIGTERM", shutdown);
  process.on("SIGINT", shutdown);
};

start();

The POST /validate endpoint fails open with status: "unknown" on API errors. Your frontend should treat unknown as “let them through, verify async.” The batch endpoint returns 202 Accepted with a job ID immediately. No waiting.

Graceful shutdown matters here. SIGTERM closes the worker (finishes current jobs), drains the queue connection, disconnects Redis, and stops the HTTP server. Without this, your Docker container kills in-flight jobs on every deploy.

Docker Setup

Two containers. Node.js for the service, Redis for everything else.

# docker-compose.yml
version: "3.8"
services:
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    command: redis-server --appendonly yes

  validator:
    build: .
    ports:
      - "3001:3001"
    depends_on:
      - redis
    environment:
      REDIS_URL: redis://redis:6379
      TRUEMAIL_API_KEY: ${TRUEMAIL_API_KEY}
      TRUEMAIL_API_URL: https://api.truemail.io/v1/verify
      PORT: 3001
      RATE_LIMIT_PER_MINUTE: 60
      BATCH_CONCURRENCY: 5
      CACHE_TTL_VALID: 86400
      CACHE_TTL_INVALID: 3600

volumes:
  redis_data:

The --appendonly yes flag enables Redis persistence. Without it, a Redis restart wipes your cache and you’re back to cold API calls for every email. The volume mount keeps the append-only file across container restarts.

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY src/ ./src/
EXPOSE 3001
CMD ["node", "src/server.js"]

npm ci over npm install. Deterministic builds from your lockfile. No surprises in production.

Testing It

Spin everything up and hit the endpoints:

docker compose up -d

# Single validation
curl -X POST http://localhost:3001/validate \
  -H "Content-Type: application/json" \
  -d '{"email": "[email protected]"}'

# Batch validation
curl -X POST http://localhost:3001/validate/batch \
  -H "Content-Type: application/json" \
  -d '{"emails": ["[email protected]", "[email protected]", "[email protected]"]}'

# Poll for results (use the jobId from the batch response)
curl http://localhost:3001/validate/JOB_ID_HERE

First single validation call hits the API. Second call for the same email returns from cache with "cached": true. That’s the 60-80% reduction in API calls. In a production app where users retry forms and the same addresses come through multiple endpoints, the cache hit rate climbs fast.

Retry Logic for Failed Validations

API calls fail. DNS hiccups, upstream timeouts, brief outages. Your batch worker already catches errors per email, but you can add automatic retries at the job level with BullMQ’s built-in retry config:

const job = await validationQueue.add(
  "validate-batch",
  { emails },
  {
    attempts: 3,
    backoff: { type: "exponential", delay: 5000 },
  }
);

Three attempts with exponential backoff: first retry after 10 seconds, second retry after 20 seconds. If the upstream API is down for 30 seconds, your job survives. If it’s down for 10 minutes, the job lands in the failed queue and you can inspect it later. Don’t retry forever. Three attempts catches transient failures without masking real problems.

What This Gets You

A Node.js email validation layer that sits between your app and the upstream API. Your app doesn’t know or care about rate limits, caching TTLs, or queue management. It sends an email, gets a result.

The three patterns here (Redis caching, BullMQ queuing, Redis-based rate limiting) work for any external API, not just email validation. Swap the validator function for a geocoding service, a payment processor, or an enrichment API. The microservice architecture stays the same.

Numbers that matter: cached responses return in under 10ms. Uncached single validations complete in 200-500ms depending on the upstream API. Batch jobs process at whatever rate your provider allows, without your app waiting for them. Redis memory usage for 100,000 cached results is roughly 50-80MB. Cheap insurance against burning API quota on addresses you already checked.

Start with the single validation endpoint. Add batch processing when your first CSV import comes in. The queue will be ready.