Email Queue Pattern

Process emails asynchronously with queues for reliable delivery, retries, and scheduled sending.

Overview#

Email queues decouple email sending from your application's request cycle. This improves response times, enables scheduled delivery, and provides built-in retry logic for failed sends.

When to use:

  • High-volume email sending
  • Scheduled emails (reminders, digests)
  • Newsletter distribution
  • Any email that should not block user requests
  • When you need retry logic for failures

Key features:

  • Database-backed queue
  • Priority levels
  • Scheduled delivery
  • Automatic retries
  • Batch processing
  • Status tracking

Code Example#

Database Schema#

1// prisma/schema.prisma 2model EmailQueue { 3 id String @id @default(cuid()) 4 to String 5 subject String 6 template String 7 data String // JSON stringified template data 8 status EmailStatus @default(PENDING) 9 priority Int @default(0) 10 scheduledAt DateTime @default(now()) 11 sentAt DateTime? 12 openedAt DateTime? 13 error String? 14 retryCount Int @default(0) 15 createdAt DateTime @default(now()) 16 updatedAt DateTime @updatedAt 17} 18 19enum EmailStatus { 20 PENDING 21 PROCESSING 22 SENT 23 FAILED 24}

Queue Email Function#

1// lib/email-queue.ts 2import { prisma } from '@/lib/db' 3import { resend } from '@/lib/email' 4import { render } from '@react-email/render' 5 6interface QueuedEmail { 7 to: string 8 subject: string 9 template: string 10 data: Record<string, any> 11 scheduledAt?: Date 12 priority?: number 13} 14 15export async function queueEmail(email: QueuedEmail) { 16 return prisma.emailQueue.create({ 17 data: { 18 to: email.to, 19 subject: email.subject, 20 template: email.template, 21 data: JSON.stringify(email.data), 22 scheduledAt: email.scheduledAt ?? new Date(), 23 priority: email.priority ?? 0, 24 status: 'PENDING' 25 } 26 }) 27} 28 29// Queue multiple emails 30export async function queueEmails(emails: QueuedEmail[]) { 31 return prisma.emailQueue.createMany({ 32 data: emails.map(email => ({ 33 to: email.to, 34 subject: email.subject, 35 template: email.template, 36 data: JSON.stringify(email.data), 37 scheduledAt: email.scheduledAt ?? new Date(), 38 priority: email.priority ?? 0, 39 status: 'PENDING' as const 40 })) 41 }) 42}

Process Queue#

1// lib/email-queue.ts 2import { templates } from '@/emails' 3 4export async function processEmailQueue() { 5 // Get pending emails that are ready to send 6 const emails = await prisma.emailQueue.findMany({ 7 where: { 8 status: 'PENDING', 9 scheduledAt: { lte: new Date() } 10 }, 11 orderBy: [ 12 { priority: 'desc' }, 13 { scheduledAt: 'asc' } 14 ], 15 take: 10 16 }) 17 18 for (const email of emails) { 19 try { 20 // Mark as processing 21 await prisma.emailQueue.update({ 22 where: { id: email.id }, 23 data: { status: 'PROCESSING' } 24 }) 25 26 // Render template 27 const Template = templates[email.template as keyof typeof templates] 28 if (!Template) { 29 throw new Error(`Template not found: ${email.template}`) 30 } 31 32 const html = await render(Template(JSON.parse(email.data))) 33 34 // Send email 35 await resend.emails.send({ 36 from: `${process.env.EMAIL_FROM_NAME} <${process.env.EMAIL_FROM_ADDRESS}>`, 37 to: email.to, 38 subject: email.subject, 39 html 40 }) 41 42 // Mark as sent 43 await prisma.emailQueue.update({ 44 where: { id: email.id }, 45 data: { status: 'SENT', sentAt: new Date() } 46 }) 47 } catch (error) { 48 // Mark as failed 49 await prisma.emailQueue.update({ 50 where: { id: email.id }, 51 data: { 52 status: 'FAILED', 53 error: error instanceof Error ? error.message : 'Unknown error', 54 retryCount: { increment: 1 } 55 } 56 }) 57 } 58 } 59}

Cron Job for Processing#

1// app/api/cron/process-emails/route.ts 2import { processEmailQueue } from '@/lib/email-queue' 3import { NextRequest, NextResponse } from 'next/server' 4 5export async function GET(request: NextRequest) { 6 // Verify cron secret 7 const authHeader = request.headers.get('authorization') 8 if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { 9 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) 10 } 11 12 await processEmailQueue() 13 14 return NextResponse.json({ success: true }) 15}
1// vercel.json 2{ 3 "crons": [{ 4 "path": "/api/cron/process-emails", 5 "schedule": "* * * * *" 6 }] 7}

Retry Logic#

1// lib/email-queue.ts 2const MAX_RETRIES = 3 3const RETRY_DELAYS = [60, 300, 900] // 1min, 5min, 15min (in seconds) 4 5export async function retryFailedEmails() { 6 const failedEmails = await prisma.emailQueue.findMany({ 7 where: { 8 status: 'FAILED', 9 retryCount: { lt: MAX_RETRIES } 10 } 11 }) 12 13 for (const email of failedEmails) { 14 const retryDelay = RETRY_DELAYS[email.retryCount] ?? RETRY_DELAYS[RETRY_DELAYS.length - 1] 15 const nextRetry = new Date(Date.now() + retryDelay * 1000) 16 17 await prisma.emailQueue.update({ 18 where: { id: email.id }, 19 data: { 20 status: 'PENDING', 21 scheduledAt: nextRetry 22 } 23 }) 24 } 25}

Priority Queue#

1// lib/email-queue.ts 2export const EMAIL_PRIORITY = { 3 CRITICAL: 100, // Password resets, 2FA codes 4 HIGH: 75, // Order confirmations, payment receipts 5 NORMAL: 50, // Notifications, updates 6 LOW: 25, // Marketing, recommendations 7 BULK: 0 // Newsletters, digests 8} as const 9 10// Usage 11await queueEmail({ 12 to: user.email, 13 subject: 'Reset your password', 14 template: 'password-reset', 15 data: { resetUrl }, 16 priority: EMAIL_PRIORITY.CRITICAL 17}) 18 19await queueEmail({ 20 to: user.email, 21 subject: 'Weekly digest', 22 template: 'digest', 23 data: { posts }, 24 priority: EMAIL_PRIORITY.LOW, 25 scheduledAt: nextSunday() 26})

Batch Emails (Newsletter)#

1// lib/email-queue.ts 2export async function queueBatchEmails( 3 recipients: string[], 4 template: string, 5 subject: string, 6 getData: (email: string) => Record<string, any> 7) { 8 const emails = recipients.map(to => ({ 9 to, 10 subject, 11 template, 12 data: JSON.stringify(getData(to)), 13 scheduledAt: new Date(), 14 priority: EMAIL_PRIORITY.BULK, 15 status: 'PENDING' as const 16 })) 17 18 // Batch insert for efficiency 19 await prisma.emailQueue.createMany({ data: emails }) 20} 21 22// Usage - Newsletter 23const subscribers = await prisma.subscriber.findMany({ 24 where: { active: true }, 25 select: { email: true } 26}) 27 28await queueBatchEmails( 29 subscribers.map(s => s.email), 30 'newsletter', 31 'Weekly Newsletter - March 2024', 32 (email) => ({ 33 email, 34 unsubscribeUrl: generateUnsubscribeUrl(email) 35 }) 36)

Queue Status Dashboard#

1// lib/email-queue.ts 2export async function getQueueStats() { 3 const [pending, processing, sent, failed] = await Promise.all([ 4 prisma.emailQueue.count({ where: { status: 'PENDING' } }), 5 prisma.emailQueue.count({ where: { status: 'PROCESSING' } }), 6 prisma.emailQueue.count({ where: { status: 'SENT' } }), 7 prisma.emailQueue.count({ where: { status: 'FAILED' } }) 8 ]) 9 10 const recentFailures = await prisma.emailQueue.findMany({ 11 where: { 12 status: 'FAILED', 13 updatedAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } 14 }, 15 orderBy: { updatedAt: 'desc' }, 16 take: 10 17 }) 18 19 return { 20 counts: { pending, processing, sent, failed }, 21 total: pending + processing + sent + failed, 22 recentFailures 23 } 24}

Usage Instructions#

  1. Create database table: Add the EmailQueue model to your Prisma schema
  2. Run migration: npx prisma db push or npx prisma migrate dev
  3. Queue emails: Use queueEmail() instead of sending directly
  4. Set up cron: Configure a cron job to process the queue
  5. Monitor status: Build a dashboard to track queue health

Best Practices#

  1. Use priorities - Critical emails (auth) should process before marketing
  2. Set retry limits - Don't retry forever; mark as permanently failed
  3. Implement backoff - Increase delay between retries
  4. Monitor the queue - Alert when failed count is high
  5. Clean up old records - Archive or delete old sent emails
  6. Batch for newsletters - Use createMany for bulk inserts
  7. Respect rate limits - Don't exceed provider limits
  8. Log everything - Track sends for debugging