How to Build a Scalable Bulk Email System with Node.js, RabbitMQ, and BullMQ

SauravSaurav
4 min read

Sending emails at scale isn't just about firing off SMTP requests. Once you're dealing with thousands (or millions) of users, whether for newsletters, notifications, or updates, you need a design that’s reliable, fault-tolerant, and doesn’t buckle under load.

In this blog, I’ll walk through a bulk mailing system built with Node.js, RabbitMQ, and BullMQ. The goal was simple: design a system that scales without being over-engineered for a project like an LMS, where mailing is important but not the core feature.


Why Not Just Loop Through Users and Send Emails?

Because your server will die. Not metaphorically, literally!

If you try to send emails to 10,000 users in a single request, you’ll:

  • Exhaust memory and CPU

  • Hit SMTP provider rate limits

  • Block your main thread

  • Lose retry tracking if anything fails

Instead, the solution is to decouple the email-sending process using queues and workers.


Architecture Overview

We use a fan-out pattern with two levels of job distribution:

  1. BulkMailJobQueue (RabbitMQ): Accepts a job for sending a mail campaign to many users.

  2. MailJobWorker: Pulls from the BulkMailJobQueue, fetches the list of recipients, and enqueues individual email jobs.

  3. transactionMailQueue (BullMQ): Stores the per-user email jobs.

  4. TransactionMailWorker: Picks up jobs from the transactionMailQueue and sends emails with personalized data.

Here's a simplified flow diagram:

                       +---------------------+
                       |                     |
                       |   Admin/Server      |
                       |                     |
                       +----------+----------+
                                  |
                                  | 1. Enqueue bulk mail job
                                  v
                       +----------+----------+
                       |    RabbitMQ         |  ← BulkMailJobQueue
                       +----------+----------+
                                  |
                                  | 2. MailJobWorker processes
                                  v
                  +---------------+----------------+
                  |    MailJobWorker (Node.js)     |
                  +---------------+----------------+
                                  |
                                  | 3. Fetch all users
                                  | 4. Enqueue per-user job
                                  v
                            +-----+-----+
                            | BullMQ     | ← transactionMailQueue
                            +-----+-----+
                                  |
                                  | 5. TransactionMailWorker
                                  |    sends email to each user
                                  v
                         +--------+--------+
                         |     SMTP/Mailer  |
                         +------------------+

Project Components

1. Enqueueing the Bulk Mail Job

This job gets triggered either by an API request or a cron job:

// bulkMailerProducer.ts
import amqp from 'amqplib';

const sendBulkMailJob = async (campaignId: string) => {
  const conn = await amqp.connect('amqp://localhost');
  const channel = await conn.createChannel();
  await channel.assertQueue('BulkMailJobQueue');

  channel.sendToQueue('BulkMailJobQueue', Buffer.from(JSON.stringify({ campaignId })));
};

2. Worker to Fan-Out Individual Mail Jobs

// mailJobWorker.ts
import amqp from 'amqplib';
import { Queue } from 'bullmq';

const transactionQueue = new Queue('transactionMailQueue');

const startWorker = async () => {
  const conn = await amqp.connect('amqp://localhost');
  const channel = await conn.createChannel();
  await channel.assertQueue('BulkMailJobQueue');

  channel.consume('BulkMailJobQueue', async (msg) => {
    if (!msg) return;
    const { campaignId } = JSON.parse(msg.content.toString());
    const users = await getAllSubscribedUsers(); // Fetch from DB

    for (const user of users) {
      await transactionQueue.add('sendMail', {
        userId: user.id,
        email: user.email,
        campaignId,
      });
    }
    channel.ack(msg);
  });
};

startWorker();

3. Transaction Mail Worker (BullMQ)

// transactionWorker.ts
import { Worker } from 'bullmq';
import { sendEmail } from './mailer';

new Worker('transactionMailQueue', async (job) => {
  const { email, campaignId } = job.data;
  const content = await getCampaignContent(campaignId);

  await sendEmail({
    to: email,
    subject: content.subject,
    html: content.body,
  });
});

Benefits of This Architecture

  • Scalability: Each part can be scaled independently; more workers, more queues, no bottlenecks.

  • Fault Tolerance: Jobs can fail individually without breaking the whole flow.

  • Retry Mechanism: BullMQ handles retries and can push failed jobs to a Dead Letter Queue.

  • Monitoring: Tools like Bull Board or RabbitMQ Management UI can help track jobs.


Scaling It Further

  • Add rate limiting with mail providers

  • Encrypt or obfuscate user data in queues

  • Use Docker to isolate each worker for containerized scaling

  • Use cron jobs to trigger campaigns at scheduled times


Final Thoughts

You don’t need Kafka or a heavy microservice setup to build reliable, scalable systems. For most real-world use cases, especially in medium-sized apps, queue-based fan-out architectures hit the sweet spot between simplicity and power.

This approach worked well in my LMS project. If you're planning a similar system, whether for bulk emails, notifications, or background processing, try keeping things modular, use queues, and design for failure.

No magic. Just queues, workers, and deliberate design.


#NodeJS #BullMQ #RabbitMQ #ScalableArchitecture #BackendEngineering #EmailSystems

0
Subscribe to my newsletter

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

Written by

Saurav
Saurav

CSE(AI)-27' NCER | AI&ML Enthusiast | full stack Web Dev | Freelancer | Next.js & Typescript | Python