The Decoupling Mandate: Why Synchronous Sends Kill Latency
In enterprise NestJS architectures, executing synchronous email requests inside HTTP controllers (such as user signup or invoice checkout) degrades customer experience. If the remote mail gateway experiences a 1.5s latency spike or temporary TCP timeout, the user HTTP connection hangs and risks timing out.
By decoupling email creation from execution with a job queue, HTTP controllers acknowledge caller requests immediately by enqueuing a job. Background workers then pull jobs and execute dispatches with exponential backoff retries. A Postgres-backed queue such as pg-boss has one more property worth choosing it for: the enqueue can join the same transaction as the row that caused it, so a crash between commit and enqueue cannot lose the email.
1. NestJS Worker Processor (email.processor.ts)
The processor starts pg-boss with the module, registers a worker, and tells permanent refusals (no retry) apart from transient failures (retry with backoff).
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import PgBoss from 'pg-boss';
export interface EmailJobData {
to: string;
subject: string;
html?: string;
text: string;
idempotencyKey: string;
}
export const EMAIL_QUEUE = 'transactional-email';
@Injectable()
export class EmailProcessor implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(EmailProcessor.name);
private readonly boss: PgBoss;
private readonly apiKey: string;
constructor(private readonly config: ConfigService) {
this.boss = new PgBoss(this.config.getOrThrow<string>('DATABASE_URL'));
this.apiKey = this.config.getOrThrow<string>('SADASEND_API_KEY');
}
async onModuleInit(): Promise<void> {
await this.boss.start();
await this.boss.createQueue(`${EMAIL_QUEUE}-dlq`);
await this.boss.createQueue(EMAIL_QUEUE, {
retryLimit: 5,
retryDelay: 2,
retryBackoff: true, // 2s, 4s, 8s, 16s, 32s
deadLetter: `${EMAIL_QUEUE}-dlq`,
});
await this.boss.work<EmailJobData>(EMAIL_QUEUE, { batchSize: 25 }, async (jobs) => {
for (const job of jobs) await this.process(job.id, job.data);
});
}
async onModuleDestroy(): Promise<void> {
await this.boss.stop();
}
private async process(jobId: string, data: EmailJobData): Promise<void> {
const res = await fetch('https://api.sadasend.com/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
'Idempotency-Key': data.idempotencyKey,
},
body: JSON.stringify({ to: data.to, subject: data.subject, html: data.html, text: data.text }),
});
if (res.status === 403 || res.status === 422) {
// A guardrail refused it (allowlist, suppression, undeliverable). Retrying cannot help,
// so log it and return: the job completes instead of burning five attempts.
const err = await res.json();
this.logger.error(`Job ${jobId} refused by SadaSend (${err.error}): ${err.message}`);
return;
}
if (!res.ok) {
// Transient: throwing hands the job back for an exponential-backoff retry.
throw new Error(`Upstream delivery failure: ${res.status}`);
}
}
}2. Enqueuing with Dead Letter Queue (DLQ) Policies
The queue above is configured with finite attempts and exponential backoff to avoid hammering remote services during network partitions; jobs that exhaust them land in the dead-letter queue for inspection.
// In your Auth or Order Service. Pass your transaction's connection as `db` to
// enqueue atomically with the row you are writing: commit and the job exists,
// roll back and it never did.
await this.boss.send(
EMAIL_QUEUE,
{
to: user.email,
subject: 'Confirm your work email',
text: `Verify your account: ${verificationUrl}`,
idempotencyKey: `verify-${user.id}`,
},
{ singletonKey: `verify-${user.id}` }, // a second enqueue of the same email is a no-op
);Send transactional emails in under 3 minutes
EU-hosted delivery, custom SMTP relay, 100% SPF/DKIM alignment, and automated suppression firewalls. 6,000 free sends every month without a credit card.
Building SadaSend — transactional email with an MCP server that has a ceiling. Writes about deliverability, email infrastructure, and what happens when you hand an autonomous agent a sending credential.