Queueing outbound email: why sends should be async
Sending email inside a web request couples your latency to someone else's SMTP server. A queue-based design with workers, retries and dead letters.

On this page(10 sections)
- What goes wrong with synchronous sending
- Your latency becomes someone else's latency
- Failures lose messages
- Retries cause duplicates
- The queued design
- The transactional outbox
- Workers and retries
- Priorities and separation
- Dead letters are a feature
- Observability
- Things that still belong in the request
- Checklist
- Key takeaways
The simplest way to send email from a web application is to open an SMTP connection or call a provider's API inside the request handler. It works in development and fails in production in ways that are hard to see: slow pages, lost messages during outages and duplicate sends after timeouts.
A queue between the request and the send fixes all three.
What goes wrong with synchronous sending
Your latency becomes someone else's latency
Sending involves DNS lookups, TCP and TLS handshakes, and a conversation with a remote server or an API that may be slow at that moment. A signup page that normally responds in 100 milliseconds can take several seconds when the email step is slow, and time out entirely when it hangs.
Failures lose messages
If the provider is briefly unavailable, the request handler has two bad choices: fail the user's action ("Signup failed, please try again") or swallow the error and lose the email. Neither is acceptable for password resets, receipts or security alerts.
Retries cause duplicates
When a slow send makes the user's browser or your load balancer give up, the user retries. The first send may well have succeeded. Now they have two welcome emails, or two receipts.
The queued design
web request ──▶ write business data + outbox row (one transaction) ──▶ respond
│
▼
relay/dispatcher ──▶ queue ──▶ worker ──▶ provider API or SMTP
│
├── success: mark sent
├── temporary failure: retry with backoff
└── permanent failure / max attempts: dead-letter
The request handler records the intent to send and returns. Workers do the slow, failure-prone part in the background, with retries.
The transactional outbox
A subtle problem appears as soon as you add a queue: the business action and the enqueue are two separate systems. If you commit the order to the database and then the enqueue fails, the receipt is never sent. If you enqueue first and the database commit fails, a receipt goes out for an order that does not exist.
The transactional outbox pattern solves this. In the same database transaction as the business change, insert a row into an outbox table describing the email to send:
BEGIN;
INSERT INTO orders (id, user_id, total) VALUES ($1, $2, $3);
INSERT INTO email_outbox (id, kind, recipient, payload, idempotency_key)
VALUES (gen_random_uuid(), 'receipt', $4, $5, 'receipt:order:' || $1);
COMMIT;
A separate dispatcher process reads unsent outbox rows and pushes them to the queue or directly to workers, marking each as dispatched. Because the outbox row commits atomically with the order, you never send for an order that does not exist, and you never lose the email for one that does. The dispatcher may push a row twice after a crash, which is why the idempotency key travels with the message.
Workers and retries
Workers should:
- Classify failures. Network errors, timeouts,
429responses and5xxresponses are temporary: retry. Validation errors and4xxresponses such as an unverified sender are permanent: do not retry; record and alert. - Back off exponentially with jitter. For example, retry after roughly 10 seconds, 1 minute, 5 minutes, 30 minutes and so on, each with a random spread so a provider outage does not produce synchronized retry storms.
- Respect
Retry-Afterwhen the provider sends one. - Reuse the idempotency key on every attempt, so a retry after an ambiguous timeout does not send twice.
- Cap attempts or total time. After the cap, move the message to a dead-letter queue.
Priorities and separation
Not all mail is equally urgent. A password reset waiting behind a 50,000-recipient digest is a support ticket waiting to happen.
| Queue | Examples | Target latency | Notes |
|---|---|---|---|
| Critical | Password resets, sign-in codes, security alerts | Seconds | Dedicated workers; small, fast |
| Transactional | Receipts, notifications | Under a minute | Normal workers |
| Bulk | Digests, announcements | Minutes to hours | Rate-limited; can pause |
Separate queues, or at least separate priorities with reserved worker capacity, keep bulk traffic from starving critical mail. Bulk queues should also be easy to pause during an incident without touching the others.
Dead letters are a feature
A dead-letter queue holds messages that exhausted their retries or failed permanently. Treat it as an operational inbox:
- Alert when anything lands there.
- Show the error, the attempts and the payload metadata.
- Provide a way to re-drive messages after fixing the cause, again using the original idempotency key.
- Expire messages that are no longer useful; a sign-in code from yesterday should not be resent.
Observability
The queue is where "did it send?" questions are answered. Track:
- Queue depth and age of the oldest message, per queue.
- Time from enqueue to provider acceptance, per priority.
- Attempt counts and retry reasons.
- Dead-letter volume.
Log each message's ID, its idempotency key and the provider's message ID together, so a support engineer can trace from a user's complaint to the exact provider response in one search.
Things that still belong in the request
A few checks are better done synchronously, before enqueuing, because the user can fix them immediately:
- Address syntax validation.
- Basic rate limiting, such as reset requests per account.
- Authorization: is this user allowed to trigger this email?
Everything involving the network goes on the queue.
Checklist
- No SMTP or provider calls inside web request handlers.
- Outbox rows written in the same transaction as business changes.
- A dispatcher moves outbox rows to the queue.
- Workers classify temporary versus permanent failures and back off with jitter.
- Idempotency keys travel with each message and are reused on retries.
- Critical, transactional and bulk mail are separated.
- Dead letters alert, explain and can be re-driven.
- Queue depth, age and enqueue-to-send latency are monitored.
Key takeaways
- Synchronous sending ties your page latency and reliability to remote systems.
- A queue with workers lets you respond quickly and retry safely in the background.
- The transactional outbox keeps business changes and emails consistent.
- Priorities, dead-letter handling and idempotency keys turn a queue into a reliable email pipeline.
Start with Koltrix
Your domain, one inbox, and an API that sends.
A team inbox where AI sorts and drafts (nothing is sent without your click), plus the transactional API and SMTP relay your product sends with. 7 days free, no card.


