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#
- Create database table: Add the EmailQueue model to your Prisma schema
- Run migration:
npx prisma db pushornpx prisma migrate dev - Queue emails: Use
queueEmail()instead of sending directly - Set up cron: Configure a cron job to process the queue
- Monitor status: Build a dashboard to track queue health
Best Practices#
- Use priorities - Critical emails (auth) should process before marketing
- Set retry limits - Don't retry forever; mark as permanently failed
- Implement backoff - Increase delay between retries
- Monitor the queue - Alert when failed count is high
- Clean up old records - Archive or delete old sent emails
- Batch for newsletters - Use createMany for bulk inserts
- Respect rate limits - Don't exceed provider limits
- Log everything - Track sends for debugging
Related Patterns#
- Transactional Email - Basic email sending
- Email Templates - React Email components
- Email Tracking - Open and click analytics