Most engineering teams ship their first email feature by calling sendEmail() inside a request handler, then wonder why their API is timing out and emails are bouncing at scale. Building email systems that scale: a hands-on guide for engineers requires rethinking how we decouple sending, template rendering, and reputation management. In this article, we'll walk through the production patterns that keep email delivery reliable and your sender reputation intact.
Queue every send — Never send email inside the request
Sending email synchronously inside your request handler is one of the first mistakes we see at scale. Email delivery can take 5–30 seconds per message. Multiply that by your request volume, and you've built a denial-of-service attack on your own infrastructure. The solution is simple: queue the send operation and return control to the user immediately.
When a user signs up or triggers an action that needs email, publish an event to a job queue. A separate worker process picks it up, renders and sends the message, and handles failures independently. This keeps your API responsive and lets you scale email sending separately from your application logic.
Here's what a basic send flow looks like in NestJS with Bull and AWS SES:
// src/email/email.controller.ts
import { Controller, Post, Body } from '@nestjs/common';
import { Queue } from 'bull';
import { InjectQueue } from '@nestjs/bull';
@Controller('auth')
export class AuthController {
constructor(@InjectQueue('email') private emailQueue: Queue) {}
@Post('signup')
async signup(@Body() body: { email: string; name: string }) {
// Save user to database first
const user = await this.userService.create(body);
// Queue the email job, return immediately
await this.emailQueue.add(
'send-welcome',
{ userId: user.id, email: user.email, name: user.name },
{ delay: 0, attempts: 3, backoff: { type: 'exponential', delay: 2000 } }
);
return { success: true, userId: user.id };
}
}
// src/email/email.processor.ts
import { Process, Processor } from '@nestjs/bull';
import { Job } from 'bull';
@Processor('email')
export class EmailProcessor {
constructor(private emailService: EmailService) {}
@Process('send-welcome')
async handleWelcome(job: Job) {
const { userId, email, name } = job.data;
try {
await this.emailService.sendWelcome(email, name);
// Log success to database for tracking
await this.eventStore.record('email.sent', { userId, email, templateId: 'welcome' });
} catch (error) {
// Retry is handled by Bull; log for debugging
console.error(`Failed to send welcome email to ${email}:`, error);
throw error; // Bull will retry
}
}
}
Notice the retry configuration: attempts: 3 with exponential backoff. Transient failures (network hiccups, SES throttling) are handled automatically. If all retries fail, we log it so the ops team can investigate. Your API response time is now decoupled from email delivery reliability.
Templates with data — Content blocks filled per user at send time
Hard-coding email content is a maintenance nightmare. Store your email templates in a database or in version control, and inject user-specific data at send time. This gives you a single source of truth for content and makes A/B testing or content updates painless.
We use a simple template + context pattern. Templates contain Handlebars placeholders (or your templating engine of choice), and the worker renders them just before sending. This also lets you test template rendering independently of your infrastructure.
// src/email/email.service.ts
import * as Handlebars from 'handlebars';
import * as AWS from 'aws-sdk';
export class EmailService {
private ses = new AWS.SES({ region: 'us-east-1' });
async sendWelcome(toEmail: string, userName: string) {
// Load template from database or file
const template = await this.templateStore.getByName('welcome');
// Render with user context
const compiled = Handlebars.compile(template.html);
const html = compiled({
userName,
activationUrl: `https://myapp.com/activate?token=xyz`,
supportEmail: 'support@myapp.com',
});
// Send via SES
return this.ses
.sendEmail({
Source: 'noreply@myapp.com',
Destination: { ToAddresses: [toEmail] },
Message: {
Subject: { Data: template.subject, Charset: 'UTF-8' },
Body: { Html: { Data: html, Charset: 'UTF-8' } },
},
})
.promise();
}
// Test template rendering without sending
async renderTemplate(templateName: string, context: Record) {
const template = await this.templateStore.getByName(templateName);
const compiled = Handlebars.compile(template.html);
return compiled(context);
}
}
// In your test suite:
// it('renders welcome email with user name', async () => {
// const html = await emailService.renderTemplate('welcome', { userName: 'Alice' });
// expect(html).toContain('Welcome, Alice');
// });
Store templates as versioned records in your database. When you update a template, increment the version and log which version was sent with each message. This gives you audit trails and makes rollbacks easy. For transactional emails (receipts, password resets), consistency and clarity matter more than creative design.
Deliverability — SPF, DKIM, DMARC and bounce handling protect your sender reputation
Sending email from a domain without proper authentication is like shipping code without tests. ISPs will flag you as spam or reject your messages outright. Three standards protect your sender reputation: SPF, DKIM, and DMARC. Set them all up before you send your first production email.
SPF (Sender Policy Framework) tells ISPs which IP addresses are authorized to send email from your domain. Add a TXT record to your DNS:
// DNS TXT record for myapp.com
v=spf1 include:amazonses.com ~all
// This says: accept mail from AWS SES IPs, softfail everything else (~all)
DKIM (DomainKeys Identified Mail) signs your email messages cryptographically. AWS SES and most providers handle this for you—just verify the domain in their console and enable DKIM signing. The provider will give you TXT records to add to your DNS.
DMARC (Domain-based Message Authentication, Reporting and Conformance) tells ISPs what to do if SPF or DKIM fail. Add a DMARC policy:
// DNS TXT record for _dmarc.myapp.com
v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@myapp.com; ruf=mailto:dmarc-forensics@myapp.com
// This says: if auth fails, quarantine the message and send us forensic reports
Monitor bounce rates constantly. Hard bounces (invalid email, domain doesn't exist) should immediately suppress that address from future sends. Soft bounces (mailbox full, temporary server issue) warrant a retry, but if they persist across multiple attempts, suppress the address too. A suppression list prevents wasted sends and protects your IP reputation.
// src/email/bounce-handler.ts
import { SQSEvent } from 'aws-lambda';
export const handleSESBounces = async (event: SQSEvent) => {
for (const record of event.Records) {
const message = JSON.parse(record.Sns.Message);
if (message.eventType === 'Bounce') {
const { bounce } = message;
// Hard bounces: suppress immediately
if (bounce.bounceType === 'Permanent') {
for (const recipient of bounce.bouncedRecipients) {
await suppressionStore.add(recipient.emailAddress, 'hard_bounce');
}
}
// Soft bounces: log for monitoring
if (bounce.bounceType === 'Transient') {
for (const recipient of bounce.bouncedRecipients) {
await bounceLog.record({
email: recipient.emailAddress,
reason: recipient.diagnosticCode,
timestamp: new Date(),
});
}
}
}
// Handle complaints (user marked as spam)
if (message.eventType === 'Complaint') {
for (const recipient of message.complaint.complainedRecipients) {
await suppressionStore.add(recipient.emailAddress, 'complaint');
}
}
}
};
Wire up SNS notifications from your email provider to trigger bounce handlers. AWS SES publishes bounce and complaint events to SNS; Lambda functions (or your queue worker) consume them and update your suppression list. This closes the feedback loop and keeps your reputation clean.
Track events — Delivered, opened, clicked and bounced feed analytics and suppression lists
You can't improve what you don't measure. Capture email delivery events (sent, delivered, opened, clicked, bounced) to feed analytics dashboards and automatic suppression lists. This gives product and ops visibility into email health and user engagement.
Most email providers offer webhooks for delivery events. Set up a simple endpoint to receive and record these events:
// src/email/webhook.controller.ts
import { Controller, Post, Body } from '@nestjs/common';
@Controller('webhooks/email')
export class EmailWebhookController {
constructor(private eventStore: EventStore) {}
@Post('sendgrid')
async handleSendGridEvents(@Body() events: SendGridEvent[]) {
for (const event of events) {
await this.eventStore.record({
type: event.event, // 'sent', 'delivered', 'open', 'click', 'bounce'
messageId: event.sg_message_id,
email: event.email,
timestamp: new Date(event.timestamp * 1000),
metadata: {
url: event.url, // for clicks
userAgent: event.useragent, // for opens
reason: event.reason, // for bounces
},
});
// Update suppression list on hard bounce or complaint
if (event.event === 'bounce' && event.type === 'permanent') {
await suppressionStore.add(event.email, 'hard_bounce');
}
if (event.event === 'spamreport') {
await suppressionStore.add(event.email, 'complaint');
}
}
return { success: true };
}
}
// Query events for dashboard
export class EmailAnalyticsService {
async getMetrics(templateName: string, days: number) {
const events = await this.eventStore.query({
templateName,
timestamp: { $gte: new Date(Date.now() - days * 86400000) },
});
return {
sent: events.filter(e => e.type === 'sent').length,
delivered: events.filter(e => e.type === 'delivered').length,
opened: events.filter(e => e.type === 'open').length,
clicked: events.filter(e => e.type === 'click').length,
bounced: events.filter(e => e.type === 'bounce').length,
openRate: events.filter(e => e.type === 'open').length / events.filter(e => e.type === 'delivered').length,
clickRate: events.filter(e => e.type === 'click').length / events.filter(e => e.type === 'delivered').length,
};
}
}
Store events in a time-series database (InfluxDB, TimescaleDB) or data warehouse (Redshift, BigQuery) for efficient querying and aggregation. This keeps your transactional database lean and lets you run complex analytics queries without impacting production performance.
Use event data to build feedback loops: if open rates drop, investigate template content or send timing. If bounce rates spike, check if you're scraping emails from untrusted sources. If complaints increase, review your unsubscribe mechanism and preference center.
Common mistakes
- Sending inside the request: Blocks your API, times out under load, and couples email reliability to application reliability. Always queue.
- No suppression list: Repeatedly sending to invalid or complainant addresses tanks your IP reputation fast. Query your suppression list before every send.
- Ignoring bounce events: Not consuming bounce notifications leaves dead addresses in your database indefinitely. Set up webhook handlers immediately.
- Hard-coded templates: Updating email content requires code deployments. Store templates in your database and render them at send time.
- No retry logic: Transient failures (SES throttling, network timeouts) will lose emails silently. Use exponential backoff with a reasonable retry limit (3–5 attempts).
- Skipping authentication setup: No SPF, DKIM, or DMARC means most of your mail lands in spam or gets rejected. This is table stakes, not optional.
Key takeaways
- Queue every email send and process it asynchronously. Never block your request handler waiting for email delivery.
- Store templates in your database and render them with user-specific context at send time. Test template rendering independently.
- Set up SPF, DKIM, and DMARC before sending any production email. Monitor bounce rates and maintain an active suppression list.
- Capture delivery events (sent, delivered, opened, clicked, bounced) via webhooks. Use these events for analytics dashboards and automatic suppression.
- Treat email infrastructure as a first-class system. It carries your brand reputation and is often the most direct channel to your users.






Comments
Loading comments…