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 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.
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 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 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 with 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.
The latency problem does go away.
Everything else gets strictly worse. 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 .
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.
In Pulsenote that's → LavinMQ → , with as a separate ingest path for provider callbacks.
The accept side: 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 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 , 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 above, with a unique index on .
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: 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.
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 — one attempt, no backoff, immediate status the customer can see.
For retryable failures, exponential backoff with 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 te