Almost every codebase I've inherited sends email the same way: somewhere inside a POST handler, between the database write and the response, there's an await on the mail provider's SDK. It works, for months. Then one afternoon your signup endpoint starts timing out, and it takes an hour to work out that the cause is your email provider having a bad day three thousand kilometres away.
I build Pulsenote, a transactional email API, so I've spent an unreasonable amount of time in the space between "your API call returned 200" and "the message is in the inbox". This post is what lives in that gap, why a try/catch doesn't cover it, and where the line sits between "you need a pipeline" and "you're overengineering a side project".
The naive version
Here's the code. You've written this.
// users.controller.ts
@Post('signup')
async signup(@Body() dto: SignupDto) {
const user = await this.users.create(dto);
await this.mailer.send({
to: user.email,
subject: 'Confirm your email',
html: renderConfirmation(user),
});
return { id: user.id };
}
Nine lines, obvious intent, no infrastructure. For a lot of applications this is genuinely the right answer, and I'll come back to that at the end. But let's be precise about what it costs, because "it's fine" and "I haven't measured it" are different statements.
It puts a third party in your request path. Your p99 for POST /signup is now your p99 plus the provider's p99. Not their median — their tail.
A slow provider becomes a slow endpoint, then no endpoint. This is the failure mode that actually takes services down. If the provider degrades to five seconds per call, every signup request holds a connection and an event-loop continuation for five seconds. Your connection pool fills, your load balancer queues, health checks fail, the pod gets restarted, and now you're down — because of email. The blast radius of a non-critical dependency became the whole endpoint.
A provider 5xx loses the mail entirely. What does your catch do? Realistically one of two things. It rethrows, so the user sees a 500 for a signup that already succeeded in the database — now you have a user row with no confirmation email and a client that will retry and hit a unique constraint. Or it swallows the error, returns 200, and the email is simply gone. No record, no retry, no way to answer "did we ever send that?"
There is no retry. SES will return a ThrottlingException with Maximum sending rate exceeded when you exceed your account's send rate (AWS docs) — AWS's own guidance is to wait and retry the send request. Inline, inside an HTTP handler, you have nowhere to wait. Your only options are to block the user or drop the message.
There is no idempotency. The client's HTTP retry — after a timeout your code caused — sends the email twice, or the user twice, or both.
There is no audit trail. When support asks "did the password reset go out to this customer at 14:12?", the honest answer is "there's a log line if the log retention hasn't rolled over".
The fix people reach for, which is worse
The instinct, once the latency problem shows up, is to stop awaiting.
@Post('signup')
async signup(@Body() dto: SignupDto) {
const user = await this.users.create(dto);
// don't block the response
this.mailer.send({ to: user.email, /* ... */ })
.catch(console.error); // <- this line is a landmine
return { id: user.id };
}
The latency problem does go away. Everything else gets strictly worse.
.catch(console.error) is silent loss with extra steps. The failure is now a log line nobody reads instead of a stack trace someone would have seen. You've converted a loud bug into a quiet one, which is the wrong direction.
There's no backpressure. Awaiting at least served as an accidental rate limiter — one in-flight send per request. Fire-and-forget lets a traffic spike launch ten thousand concurrent sends at a provider that will throttle you, and the throttling errors land in console.error.
And it's lost on restart. A floating promise lives in one process's heap. On Kubernetes — which is where Pulsenote runs, DOKS in LON1 — pods restart constantly: deploys, node drains, evictions, OOM kills, scale-downs. Every rollout silently drops whatever was in flight, and "we rolled out at 14:10" is never the first hypothesis when a customer reports a missing email.
Fire-and-forget doesn't solve the problem. It moves it somewhere you can't see.
Enqueue, then process
The actual fix is to split the operation at the point where you make a promise to the caller.
An HTTP handler should do exactly the work needed to accept responsibility for a message, then return. Delivering it happens elsewhere, on its own schedule, with its own failure handling. That boundary — accept vs. deliver — is the whole idea. Everything else is implementation.
client
│ POST /v1/notifications
▼
┌──────────────┐ 1. authenticate tenant
│ api-gateway │ 2. INSERT message (status=queued) ◄── source of truth
└──────┬───────┘ 3. publish {messageId}
│ 4. return 202 Accepted
▼
┌──────────────┐
│ LavinMQ │ durable queue — a pointer, not the payload
└──────┬───────┘
│ consume
▼
┌──────────────┐ load row, check status, render,
│ email-worker │ send, record attempt, ack
└──────┬───────┘
│ ┌──────────────┐
│ on give-up │ dead letter │
│ ───────────►└──────────────┘
▼
┌──────────────┐
│ AWS SES │ ──────► recipient's mail server
└──────┬───────┘
│ Delivery / Bounce / Complaint (SNS)
▼
┌──────────────────┐ reconcile status onto the row,
│ delivery-tracker │ auto-suppress bad recipients
└──────────────────┘
In Pulsenote that's api-gateway → LavinMQ → email-worker, with delivery-tracker as a separate ingest path for provider callbacks. The accept side:
// notifications.controller.ts
@Post()
@UseGuards(ApiKeyGuard)
async enqueue(@Tenant() tenant: TenantContext, @Body() dto: SendEmailDto) {
const message = await this.messages.accept(tenant, dto);
return { id: message.id, status: message.status }; // 202
}
// messages.service.ts
async accept(tenant: TenantContext, dto: SendEmailDto): Promise<Message> {
const message = await this.dataSource.transaction(async (em) => {
const existing = await em.findOne(Message, {
where: { tenantId: tenant.id, idempotencyKey: dto.idempotencyKey },
});
if (existing) return existing; // replay, not a new send
return em.save(em.create(Message, {
tenantId: tenant.id,
to: dto.to,
templateId: dto.templateId,
payload: dto.variables,
idempotencyKey: dto.idempotencyKey,
status: MessageStatus.QUEUED,
attempts: 0,
}));
});
// publish AFTER the row is committed
await this.broker.publish('email.send', { messageId: message.id });
return message;
}
Two details in there matter more than the rest.
The row is committed before the message is published. If you publish first and the transaction rolls back, the worker consumes a message ID that doesn't exist. Commit first: worst case the publish fails and you have a queued row nobody picked up, which a sweeper query finds in seconds. An orphaned row is recoverable; a phantom job is not. (To close that window entirely, the transactional outbox pattern is the next step up — worth it eventually, not on day one.)
The queue carries a pointer, not the payload. The database row is the source of truth. The queue is a transport — no schema evolution, no query interface, no history. If the broker loses a message you can requeue from the table; if the payload only existed in the message, it's gone. And when support asks what happened to a specific email, you need SELECT, not a queue browser.
The caller gets a 202 and an ID. That's an honest response — "I have durably accepted this and I will tell you what happens to it" — and a much stronger promise than a 200 that meant "an SDK call didn't throw".
Idempotency, and why "exactly once" is a lie
Every real broker gives you at-least-once delivery. LavinMQ, RabbitMQ, SQS, Kafka — the guarantee is the same, because the alternative requires a distributed transaction between your broker and your side effect, and the side effect here is an HTTP call to Amazon.
Your worker crashes after SES accepts the message but before the ack. The broker sees an unacked message and redelivers. That's not a bug, that's the design. Your worker will see duplicates. Plan for it. So dedupe in two places.
At the edge, an idempotency key from the client — the accept() above, with a unique index on (tenant_id, idempotency_key). A client retrying a timed-out POST gets the original message back instead of a second send. Make the key required for anything expensive, or derive one and document it.
In the worker, a status check inside a row lock:
// email.worker.ts
@RabbitSubscribe({ exchange: 'pulsenote', routingKey: 'email.send' })
async handle(msg: { messageId: string }) {
const claimed = await this.dataSource.transaction(async (em) => {
const row = await em.findOne(Message, {
where: { id: msg.messageId },
lock: { mode: 'pessimistic_write' },
});
if (!row) return null;
if (row.status !== MessageStatus.QUEUED) return null; // already handled
row.status = MessageStatus.SENDING;
row.attempts += 1;
return em.save(row);
});
if (!claimed) return; // duplicate delivery — ack and move on
await this.deliver(claimed);
}
The lock is what makes this safe when two consumers get the same message concurrently, which happens whenever you scale the worker past one replica. Without it you have a check-then-act race and you'll ship the occasional double email.
"Exactly-once delivery" as a product claim generally means exactly-once processing — at-least-once transport plus deduplication at the consumer. Which is what you just built. There's no version of this where the network stops being able to lose an ack.
Retries: not all failures are equal
The single most valuable thing the worker does is classify errors. Retrying a hard bounce is worse than useless — it inflates the bounce rate that your provider judges you on.
function classify(err: unknown): 'retry' | 'terminal' {
const name = (err as { name?: string })?.name ?? '';
const status = (err as { $metadata?: { httpStatusCode?: number } })
?.$metadata?.httpStatusCode ?? 0;
// retryable: throttling, provider 5xx, transport failures
if (name === 'ThrottlingException') return 'retry';
if (name === 'TooManyRequestsException') return 'retry';
if (status === 429 || status >= 500) return 'retry';
if (['ETIMEDOUT', 'ECONNRESET', 'EAI_AGAIN'].includes((err as any)?.code)) {
return 'retry';
}
// terminal: the request itself is wrong, or the recipient is unreachable
if (name === 'MessageRejected') return 'terminal';
if (name === 'AccountSuppressionListException') return 'terminal';
if (status >= 400 && status < 500) return 'terminal';
return 'retry'; // unknown → retry, and alert on it
}
Retryable means the same request might succeed later: throttling, 5xx, connection resets, DNS blips. Terminal means it will never succeed: malformed address, unverified sending identity, a recipient on the suppression list. Terminal failures go straight to failed — one attempt, no backoff, immediate status the customer can see.
For retryable failures, exponential backoff with jitter:
function nextDelayMs(attempt: number): number {
const base = Math.min(1_000 * 2 ** attempt, 15 * 60_000); // cap at 15 min
return Math.round(base * (0.5 + Math.random() * 0.5)); // full-ish jitter
}
The jitter is not decoration. When a provider throttles you it throttles everything at once, so every failed message becomes due for retry at the same instant. Without jitter your retries arrive as a synchronised thundering herd and get throttled again, in lockstep, forever. Spreading them out is the difference between draining a backlog and oscillating.
The cap matters too. AWS's guidance for a throttling error is to wait — their docs suggest an interval of up to 10 minutes before retrying (SES quota errors). Backing off for hours on a transactional email is pointless; a password reset that arrives 90 minutes late has already failed at its job. Pick a max attempt count (I use 5) and a delay ceiling in the low tens of minutes, then stop.
When attempts are exhausted, the message goes to a dead-letter queue and the row goes to failed. The DLQ is not a graveyard — it's an inbox for a human. Something is broken if it's non-empty, and the message body is enough to replay once you've fixed it.
The 250 is not delivery
Here is the part that a queue alone doesn't solve, and the reason email is genuinely harder than most async work.
SES accepting your message — a MessageId on the API, a 250 on SMTP — means only that SES will attempt delivery. AWS states it plainly: the Send event means "the send request was successful and Amazon SES will attempt to deliver the message to the recipient's mail server" (monitoring sending activity). Actual Delivery, Bounce and Complaint are separate events that arrive later, over SNS or an event destination, on the receiving world's schedule.
So your message has a state machine, not a boolean:
queued ──► sending ──► sent ──┬──► delivered
│ │ ├──► bounced (hard → suppress)
│ └──► failed ├──► complained (→ suppress)
└──► failed └──► delayed ──► delivered | bounced
sent and delivered are different columns and different truths. A system that only models "did the API call succeed" will report 100% success while a domain silently rejects every message you send it.
This is what delivery-tracker exists for: a separate ingest path that verifies SNS signatures, writes the event, and reconciles status onto the message row. Separate because it's driven by an external system with its own retry behaviour — you do not want provider webhook traffic sharing a deployment with your customer-facing API.
And suppression has to be automatic. On a hard bounce or a complaint, the recipient goes on a suppression list and future sends to that address are rejected at accept time, before they ever reach the provider. The reason is commercial, not aesthetic. SES will place your account under review if your bounce rate reaches 5%, and may pause your sending entirely at 10%; for complaints those numbers are 0.1% and 0.5% (SES reputation metrics). Those are small numbers. Manual suppression is not a control that operates at that resolution — by the time a human notices, the rate is already set. (SES maintains its own account-level suppression list, but you want your own too: yours is per-tenant, queryable, and lets you reject at the API boundary instead of burning a send.)
Multi-tenancy: one noisy tenant, everyone's problem
If you're building this for one application you can skip ahead. If you're building a platform, fairness is a first-class concern, because your provider quota is a shared, finite resource. A new SES account starts in the sandbox at 200 messages per 24 hours and 1 message per second (production access docs); production limits are higher but still an account-wide ceiling you can hit.
A single FIFO queue means one tenant dumping 50,000 messages puts every other tenant's password reset behind 50,000 of them. The queue is fair in ordering and grossly unfair in outcome. Two mechanisms fix most of it:
- Admit at the edge. Enforce a per-tenant rate limit and plan quota at accept time, in the gateway — a token bucket in Redis keyed by tenant. Rejecting with a 429 the client can back off from is far better than accepting work you'll deliver hours late. Backpressure the caller can see is a feature.
- Don't let one tenant own the consumers. Prefetch of 1 per consumer plus a bounded per-tenant in-flight count stops a single tenant occupying every worker. Separate queues by priority class if you have a real distinction between transactional and bulk — and if you're a transactional API, you should.
The global send rate also needs a limiter in front of the provider, shared across worker replicas, set below your actual SES rate. Retrying throttles works; not being throttled works better.
What to log, what to alert on
Structured, on every attempt: messageId, tenantId, templateId, attempt, status, provider MessageId, error class, latency. Recipient address hashed or redacted depending on where the logs go. The provider's message ID is the join key for every "what happened to this email" investigation — without it you cannot correlate your row with their events.
Alert on a much shorter list:
- DLQ depth > 0. Not a threshold. Any dead-lettered message means something needs a human.
- Bounce rate approaching 5%, complaint rate approaching 0.1% — per tenant and account-wide. Alert well below the provider's line, because by the time you cross it the damage is a trailing average you can't undo quickly.
- Queue depth trending up over N minutes. Depth is meaningless as an instant value and diagnostic as a derivative: consumers are slower than producers.
-
Oldest message age in
queued. The one metric that maps directly to what a user experiences. Queue depth can look healthy while one message sits stuck for an hour.
What I would not alert on: individual send failures. That's what retries are for, and alerting on transient noise trains you to ignore the channel — which is how you miss the DLQ page.
When you genuinely don't need any of this
I'd rather be useful than sell you architecture, so: most applications don't need this pipeline.
If you're sending ten emails a day from a side project, await mailer.send() in the handler is correct. Fewer moving parts, no broker to operate, no worker to deploy, and the failure mode — you notice an email didn't arrive and click resend — costs you a minute. Building a durable pipeline for that volume is a way of avoiding the harder work of getting users. Roughly where I'd draw the lines:
-
Inline await — low volume, email isn't load-bearing, you'd notice a failure yourself. Add a
messagestable anyway, purely for the audit trail. That's a one-hour change with permanent value. - Queue + worker — the email is part of a flow a user is waiting on (signup, reset, receipt), or a provider outage would mean silent loss, or you have enough volume to hit rate limits. Note that this doesn't require Kafka. A single durable queue and one worker process is most of the benefit.
- Full pipeline (idempotency keys, classified retries, DLQ, webhook ingest, automatic suppression, per-tenant fairness) — you're sending on behalf of other people, or the mail is regulated, or you're big enough that your bounce rate is your provider relationship.
The honest test is a question: if your provider returned 503 for the next thirty minutes, what would happen? If the answer is "some emails wouldn't arrive and I'd resend them" — you're fine, stop reading. If it's "I don't know" or "we'd lose them and never find out" — that's a queue-shaped hole, and no amount of try/catch fills it.
One product note, clearly marked: everything above is the architecture behind Pulsenote, which is what I do instead of asking you to build it — a transactional email API with the queue, retries, suppression and delivery tracking already wired up, free tier at 100 emails/month. If you'd rather own the pipeline yourself, the post above is the whole design, and I'd genuinely rather you build it well than not build it at all.
—
Top comments (0)