🚀 How to Build Scalable Email Verification in Node.js Like Real Apps Do

Sangam MundheSangam Mundhe
3 min read

Checkout the Devflow - Devflow

Introduction

Most beginner tutorials send emails directly inside route handlers.
It works, but it’s not scalable.

What if the email server is slow? What if you're handling 1,000 requests per minute?

In this blog, I’ll show you how I built an email OTP verification flow like real-world apps — using Node.js, Redis, bcrypt, and BullMQ queue workers.

Just like how Dev.to, Hashnode, or Slack verify your email — but coded from scratch 💻

Why Email Verification Matters

Stops spam accounts
Confirms user identity
Used in signup, password reset, 2FA, and onboarding

Whether you're building a SaaS app or a side project, email verification is a must-have step.

How Email Verification Works (Behind the Scenes)

Here’s the step-by-step breakdown:

1. User clicks “Verify Email”
2. Backend generates a random 6-digit OTP
3. OTP is hashed with bcrypt
4. Hashed OTP is stored in Redis (with 5-min expiry)
5. OTP is queued using BullMQ to be emailed
6. Email gets delivered to the user
7. User submits the OTP for verification
8. Backend retrieves the hashed OTP from Redis
9. If it matches → User is marked isVerified: true

This is NOT login — it’s just email identity check.


Tech Stack Used

TechPurpose
Node.js + ExpressREST API
RedisTemporary storage of OTP
bcryptSecurely hash OTP
BullMQQueue system to handle async emails
Nodemailer / ResendSend OTP via email

Step-by-Step Implementation

1. Generate & Store OTP in Redis

const otp = Math.floor(100000 + Math.random() * 900000).toString();
const hashedOtp = await bcrypt.hash(otp, 10);

await redisClient.set(email, hashedOtp, 'EX', 300); // expires in 5 min

Why bcrypt? Even if Redis is exposed, OTPs stay secure
Why Redis? Fast, temporary, auto-expiring memory store

2. Queue the Email via BullMQ

await emailQueue.add('sendOtpEmail', { email, otp });
  • Email is sent asynchronously

  • Backend doesn't wait for the email to send

  • This keeps your server fast and responsive

3. Process the Queue and Send the OTP Email

emailQueue.process('sendOtpEmail', async (job) => {
  const { email, otp } = job.data;
  await sendEmail(email, otp); // Use Resend, SendGrid, Nodemailer
});

Retry failed jobs
Monitor email logs
Use multiple workers at scale

4. Verify OTP

const storedOtp = await redisClient.get(email);
if (!storedOtp) return res.status(400).json({ error: "OTP expired" });

const isValid = await bcrypt.compare(userOtp, storedOtp);
if (!isValid) return res.status(400).json({ error: "Invalid OTP" });


await db.user.update({
  where: { email },
  data: { isVerified: true },
});

This step completes the verification.
Now the user has isVerified: true in their profile.

Real-World Security Practices

  • Hash OTPs — don’t store them as plain text

  • Expire OTPs — use Redis TTL

  • Rate limit OTP requests (to avoid spam/flooding)

  • Never log OTPs

  • Use verified sender emails with proper headers (SPF, DKIM)

Diagram of the Architecture

Final Thoughts

This is how real-world products verify email without blocking the backend or compromising on speed and security.

OTPs sent in the background
Hashed and temporary
Built for scale

TL;DR

  • Email verification is a common user onboarding step

  • Do NOT send emails directly in route handlers

  • Use Bull queue + Redis to make it efficient

  • bcrypt protects the OTP

  • Simple, secure, and scalable

Follow My Dev Journey

I’m building a real-world Q&A platform like Quora — DevFlow — in public!

Follow me on Twitter for daily dev insights
x/sangammundhe

#NodeJS #EmailVerification #MERNStack #BuildInPublic #BackendDev #SystemDesign #Redis #BullMQ #100DaysOfCode #WebDev

0
Subscribe to my newsletter

Read articles from Sangam Mundhe directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Sangam Mundhe
Sangam Mundhe