Building a Robust Email Queue System with BullMQ and Redis

Learn how to implement a production-ready email queue system using BullMQ and Redis in your Next.js application. Handle retries, delays, and failures gracefully.

December 6, 2024
4 min read
By FastSaaS Team

Building a Robust Email Queue System with BullMQ and Redis

Sending emails directly from your API can cause timeouts and poor user experience. A queue system solves this by processing emails asynchronously in the background.

Why Use Email Queues?

  1. Improved response times - API returns immediately
  2. Automatic retries - Failed emails retry automatically
  3. Rate limiting - Control email sending speed
  4. Reliability - Emails persist even if server restarts

Architecture Overview

User Action → API Endpoint → Add to Queue → Queue Worker → Email Provider
                                   ↓
                               Redis Store

Setup

Install Dependencies

npm install bullmq ioredis resend

Configure Redis Connection

// lib/redis.ts
import { Redis } from "ioredis";

export const redis = new Redis(
  process.env.REDIS_URL || "redis://localhost:6379"
);

Create Email Queue

// lib/queue/email-queue.ts
import { Queue, Worker, Job } from "bullmq";
import { redis } from "../redis";
import { Resend } from "resend";

const resend = new Resend(process.env.RESEND_API_KEY);

export interface EmailJob {
  to: string;
  subject: string;
  html: string;
  from?: string;
  replyTo?: string;
}

// Create the queue
export const emailQueue = new Queue<EmailJob>("emails", {
  connection: redis,
  defaultJobOptions: {
    attempts: 3,
    backoff: {
      type: "exponential",
      delay: 1000,
    },
    removeOnComplete: 100, // Keep last 100 completed
    removeOnFail: 1000, // Keep last 1000 failed
  },
});

// Create the worker
export const emailWorker = new Worker<EmailJob>(
  "emails",
  async (job: Job<EmailJob>) => {
    const { to, subject, html, from, replyTo } = job.data;

    console.log(`Processing email job ${job.id} to ${to}`);

    const { data, error } = await resend.emails.send({
      from: from || "FastSaaS <noreply@fastsaas.cloud>",
      to,
      subject,
      html,
      replyTo,
    });

    if (error) {
      console.error(`Email failed: ${error.message}`);
      throw error; // Triggers retry
    }

    console.log(`Email sent successfully: ${data?.id}`);
    return data;
  },
  {
    connection: redis,
    concurrency: 5, // Process 5 emails at a time
  }
);

Adding Emails to Queue

// lib/email/send-email.ts
import { emailQueue } from "../queue/email-queue";

export async function sendEmail(options: {
  to: string;
  subject: string;
  html: string;
  delay?: number;
  priority?: number;
}) {
  const { to, subject, html, delay, priority } = options;

  const job = await emailQueue.add(
    "send-email",
    { to, subject, html },
    {
      delay, // Delay in milliseconds
      priority, // Lower = higher priority
    }
  );

  return job.id;
}

// Usage examples

// Immediate send
await sendEmail({
  to: "user@example.com",
  subject: "Welcome!",
  html: "<h1>Welcome to FastSaaS!</h1>",
});

// Delayed send (1 hour)
await sendEmail({
  to: "user@example.com",
  subject: "How are you doing?",
  html: "<p>Just checking in...</p>",
  delay: 60 * 60 * 1000,
});

// High priority (payment confirmations)
await sendEmail({
  to: "user@example.com",
  subject: "Payment Received",
  html: "<p>Thank you for your purchase!</p>",
  priority: 1,
});

Email Templates

// lib/email/templates.ts

export const templates = {
  welcome: (name: string) => ({
    subject: `Welcome to FastSaaS, ${name}!`,
    html: `
      <h1>Welcome, ${name}!</h1>
      <p>We're excited to have you on board.</p>
      <a href="https://fastsaas.cloud/dashboard">Go to Dashboard</a>
    `,
  }),

  passwordReset: (resetUrl: string) => ({
    subject: "Reset Your Password",
    html: `
      <h1>Password Reset</h1>
      <p>Click the link below to reset your password:</p>
      <a href="${resetUrl}">Reset Password</a>
      <p>This link expires in 1 hour.</p>
    `,
  }),

  purchaseConfirmation: (product: string, amount: number) => ({
    subject: "Purchase Confirmed!",
    html: `
      <h1>Thank You for Your Purchase!</h1>
      <p>You've purchased: ${product}</p>
      <p>Amount: $${amount}</p>
    `,
  }),
};

Queue Dashboard API

// app/api/admin/email-queue/route.ts
import { NextResponse } from "next/server";
import { emailQueue } from "@/lib/queue/email-queue";

export async function GET() {
  const [waiting, active, completed, failed] = await Promise.all([
    emailQueue.getWaitingCount(),
    emailQueue.getActiveCount(),
    emailQueue.getCompletedCount(),
    emailQueue.getFailedCount(),
  ]);

  const recentJobs = await emailQueue.getJobs(
    ["completed", "failed", "waiting", "active"],
    0,
    10
  );

  return NextResponse.json({
    counts: { waiting, active, completed, failed },
    recentJobs: recentJobs.map((job) => ({
      id: job.id,
      name: job.name,
      status: job.finishedOn
        ? "completed"
        : job.processedOn
        ? "active"
        : "waiting",
      to: job.data.to,
      subject: job.data.subject,
      attempts: job.attemptsMade,
      createdAt: job.timestamp,
    })),
  });
}

Event Handling

// Handle queue events
emailWorker.on("completed", (job) => {
  console.log(`✅ Email ${job.id} sent to ${job.data.to}`);
});

emailWorker.on("failed", (job, error) => {
  console.error(`❌ Email ${job?.id} failed: ${error.message}`);
  // Could notify admin or log to error tracking
});

emailWorker.on("stalled", (jobId) => {
  console.warn(`⚠️ Email ${jobId} stalled`);
});

Production Considerations

  1. Run worker in separate process - Don't block your web server
  2. Monitor queue health - Set up alerts for failures
  3. Handle duplicates - Use unique job IDs
  4. Clean up old jobs - Prevent Redis memory bloat

Conclusion

A queue-based email system is essential for production SaaS applications. FastSaaS includes this complete implementation out of the box.

Get FastSaaS with built-in email queue system!