18.7. Background Email Delivery
Table of Contents
Why Send Emails in the Background?
Whenever your backend sends an email, it usually talks to an SMTP server over the network. That can be slow and unreliable. If you send emails directly inside your API handler:
- The user waits until the email is sent.
- If the SMTP server is slow, your request is slow.
- If sending fails, you may have to retry, which makes the user wait even longer.
- A spike in traffic can create a spike in email sending, which can overload your app.
Typical examples:
- User registration: send a verification email.
- Password reset: send a reset link.
- Order confirmation: send a receipt.
- Weekly digest or newsletter: send to many users.
In all these cases, the user should not have to wait for the email. The API should respond quickly, and the email should be sent in the background.
Important rule: Do not block user requests while sending emails. Use background processing so responses stay fast and reliable.
Background email delivery solves this by:
- Accepting the user request.
- Storing an “email job” somewhere.
- Returning a response quickly.
- Processing the job later, in a separate worker process.
Synchronous vs Background Email Sending
Synchronous Email Sending
Synchronous means “do it now, in this request.”
In a typical API endpoint:
def register_user(request):
user = create_user(request)
send_verification_email(user.email) # slow operation
return {"message": "User created"}Problems with this approach:
- If
send_verification_emailtakes 3 seconds, your whole request takes 3+ seconds. - If the SMTP server times out, your request may fail.
- If you need to retry, you may timeout or block your web server workers.
For small side projects or local testing, synchronous sending is fine. But it does not scale.
Background Email Sending
Background sending separates “accepting the request” from “doing the work.” The endpoint only describes the work to do and returns, and a background worker does the actual sending.
Conceptually:
def register_user(request):
user = create_user(request)
enqueue_email_job(
to=user.email,
subject="Verify your account",
template="verify.html",
context={"token": user.verification_token},
)
return {"message": "User created"}A separate worker:
def worker_loop():
while True:
job = get_next_email_job()
send_email(job.to, job.subject, job.template, job.context)Benefits:
- Fast API responses.
- Controlled concurrency for sending emails.
- Easier retry logic and error handling.
- Less coupling between your API and your SMTP infrastructure.
Key idea: Treat every email as a job that can be queued, retried, and processed by a worker, instead of something that must complete before returning an API response.
Background Email Patterns
There are several common patterns to implement background email delivery. The core idea is always “queue work, then process it.”
Pattern 1: Fire-and-Forget in the Application Server
Some frameworks let you schedule “background tasks” inside the same process that handles HTTP requests.
Example idea:
@app.post("/register")
def register(user_data, background_tasks: BackgroundTasks):
user = create_user(user_data)
background_tasks.add_task(
send_verification_email,
user.email,
user.verification_token
)
return {"message": "User created"}Characteristics:
- Simple to set up.
- No external queue system needed.
- Good for small apps or low volume email.
Limitations:
- If the server process crashes after responding but before the task finishes, the email may be lost.
- Not ideal for heavy or long-running email tasks.
- Harder to scale across multiple machines.
Use this when:
- You are starting out.
- You have low traffic.
- Losing an occasional non-critical email is acceptable.
Pattern 2: Message Queue + Worker
A more robust pattern uses:
- A message queue (like Redis, RabbitMQ, SQS).
- A worker process that listens to the queue and sends emails.
High-level flow:
- API receives a request that should trigger an email.
- API creates a job description (JSON object) and pushes it into a queue.
- The API returns a response immediately.
- A worker process constantly reads jobs from the queue.
- For each job, the worker calls
send_email. - If sending fails, the worker can retry or move the job to a “dead letter” queue.
Typical job structure:
{
"type": "send_email",
"to": "user@example.com",
"subject": "Reset your password",
"template": "password_reset.html",
"context": {
"reset_link": "https://example.com/reset?token=abc123"
},
"attempt": 1
}Benefits:
- Decouples API from email sending.
- Reliable and scalable.
- Easy to add multiple workers for throughput.
- Can centralize retry logic and error handling.
This is a very common pattern for production email delivery.
Pattern 3: Task Queue Framework (Celery, RQ, etc.)
Task queue frameworks sit on top of message queues and give you:
- Task definitions as normal functions.
- Automatic serialization of arguments.
- Logging, retries, and scheduling.
- Tools to monitor jobs.
Conceptually, you define a background task:
@task
def send_verification_email_task(to_email, token):
send_verification_email(to_email, token)Then call it from your API:
def register_user(request):
user = create_user(request)
send_verification_email_task.delay(user.email, user.verification_token)
return {"message": "User created"}The framework handles:
- Pushing the job to the queue.
- Running the task in workers.
- Retrying if it fails.
This is a common choice for real projects because it gives you a lot of functionality with minimal boilerplate.
What to Put in an Email Job
A background “email job” should contain enough information to build and send the email, but not too much. You have two main options.
Option 1: Store Full Email Data
Store everything needed:
- Recipient address.
- Subject.
- Template name.
- Template variables (context).
- Optional headers.
Example job:
{
"to": "user@example.com",
"subject": "Welcome to Example",
"template": "welcome.html",
"context": {
"first_name": "Alice",
"dashboard_url": "https://example.com/dashboard"
}
}Worker:
def process_email_job(job):
html = render_template(job["template"], job["context"])
send_raw_email(job["to"], job["subject"], html)Pros:
- Worker does not need to call your database for user data.
- Simple and fast for the worker.
Cons:
- If data changes between enqueue and send, the email uses the old data.
- Sensitive data might be stored in the queue if you are not careful.
Use this when:
- Data in the email is small and not very sensitive.
- It is ok if the email reflects the state at the time of the job creation.
Option 2: Store References, Fetch Data Later
Store only IDs or references, and let the worker fetch data from your database.
Example job:
{
"user_id": 123,
"type": "welcome_email"
}Worker:
def process_email_job(job):
user = get_user_by_id(job["user_id"])
subject, html = build_welcome_email(user)
send_raw_email(user.email, subject, html)Pros:
- Email can use the most recent data.
- Less sensitive data in the queue.
Cons:
- Worker needs database access.
- If the referenced record is deleted, the email may fail.
Use this when:
- Email content depends heavily on database state.
- You care about always using the latest information.
Rule of thumb: Store only what you need to build the email reliably. Avoid putting secrets or large payloads into the queue unless absolutely necessary.
Retry Strategies for Email Jobs
Email sending is often unreliable. SMTP servers can:
- Be temporarily down.
- Throttle you.
- Reject messages for transient reasons.
Background processing makes it easier to retry logically instead of immediately.
Basic Retry With Counter
Each job keeps track of how many attempts have been made.
Example job fields:
{
"to": "user@example.com",
"subject": "Verify your account",
"template": "verify.html",
"context": {"token": "abc123"},
"attempt": 1
}Worker logic (conceptual):
MAX_ATTEMPTS = 5
def process_email_job(job):
try:
send_email(job)
except TemporaryEmailError:
if job["attempt"] < MAX_ATTEMPTS:
job["attempt"] += 1
requeue_with_delay(job, delay=calculate_delay(job["attempt"]))
else:
move_to_dead_letter(job)
except PermanentEmailError:
move_to_dead_letter(job)Exponential Backoff
You usually do not want to retry every few seconds forever. A common pattern is “exponential backoff,” where you wait longer after each failure.
Example formula:
$$
\text{delay\_seconds} = \min(2^{n} \times 10, 3600)
$$
Where:
- $n$ is the attempt number, starting at 1.
- 10 is the base delay in seconds.
- 3600 is the maximum delay (1 hour).
So the delays are: 20s, 40s, 80s, 160s, 320s, etc, capped at 1 hour.
Exponential backoff rule: Increase the delay after each failed attempt so you do not overwhelm the SMTP server or your own system with rapid retries.
Dead Letter Queue
After too many failures, an email job should not be retried automatically.
Instead:
- Move it to a dead letter queue.
- Store information about the error.
- Optionally show this in an admin panel.
This lets you:
- Inspect problematic emails.
- Manually fix addresses or configuration.
- Manually re-send if appropriate.
Temporary vs Permanent Errors
Classify errors so that you can decide if you should retry.
Examples:
| Error type | Example | Retry? |
|---|---|---|
| Network timeout | SMTP server did not respond | Yes |
| 4xx temporary SMTP code | “Mailbox temporarily unavailable” | Yes |
| DNS lookup failure | Cannot resolve SMTP host | Yes |
| 5xx permanent “user unknown” | Address does not exist | No |
| Invalid “to” email format | “abc@@example.com” | No |
| Misconfigured credentials | Wrong SMTP password | Possibly |
Treat temporary errors with retries and exponential backoff, and treat permanent errors as failures that go to the dead letter queue.
Idempotency and Duplicate Emails
Sometimes your background system can process the same job twice. For example:
- A worker crashes after sending the email but before acknowledging the job.
- The queue system decides to deliver the job again.
If your sending logic is naive, the user may receive 2 identical emails.
To avoid this, you can add idempotency to your email sending.
Using a Unique Email Job ID
Give each email job a unique identifier, and store a record that it was sent.
Example table email_log:
| id | job_id | type | to_email | sent_at |
|---|---|---|---|---|
| 1 | "reset-123-001" | "passwordReset" | "user@example.com" | 2026-08-20 10:01:02 |
Process logic:
def process_email_job(job):
if email_log_exists(job["job_id"]):
# Already sent, do nothing
return
send_email(job)
save_email_log(job["job_id"], job["type"], job["to"])If the queue delivers the same job again, the worker checks the log and skips sending.
Idempotent Design at Higher Level
Sometimes you may design at a higher level:
- For password reset, only the last reset token is valid.
- Even if multiple emails go out, only the link with the newest token will work.
This does not fully prevent duplicates, but it reduces the harm if they happen.
Idempotency rule: Email jobs should be designed so that processing the same job more than once does not result in unexpected or harmful behavior.
Prioritizing and Categorizing Emails
Not all emails are equal. Some must be sent immediately, others can wait.
Typical categories:
- Transactional emails: registration, password reset, order confirmation. Usually high priority.
- Notification emails: “Someone liked your post,” activity digest. Medium priority.
- Marketing / bulk emails: promotions, newsletters. Low priority.
In a background system, you can:
- Use separate queues per category, like
high_priority,low_priority. - Run more workers for high priority queues.
- Rate limit marketing emails separately.
Example:
| Queue name | Purpose | Workers |
|---|---|---|
email_high | Password resets, verification | 5 |
email_medium | Notifications | 3 |
email_low | Newsletters | 1 |
Your API code decides which queue to use based on email type.
This prevents a huge newsletter sending from delaying critical password reset messages.
Observability: Logging and Monitoring Email Jobs
Background email delivery should be observable so you can:
- See how many emails are being sent.
- Detect failures.
- Debug problems.
Logging
Log important events:
- When a job is created.
- When sending succeeds.
- When sending fails, including exception details.
Example log entries:
INFO Created email job 123 type=verification to=user@example.com
INFO Sent email job 123 to=user@example.com in 0.82s
ERROR Failed email job 124 to=user@example.com attempt=3 error="SMTP timeout"Useful logging fields:
| Field | Description |
|---|---|
| job_id | Unique ID for the job |
| type | Email type, like verification |
| to_email | Recipient address (maybe masked) |
| attempt | Attempt number |
| duration_ms | Time taken to send |
| error | Error message or exception type |
Metrics
Track metrics so you can see trends:
- Emails sent per minute.
- Success rate.
- Failure rate.
- Average sending duration.
- Queue length.
These help you answer questions like:
- Are we sending more emails than usual?
- Are failures spiking?
- Is the queue backing up?
Alerts
Set alerts for conditions such as:
- High failure rate.
- Queue length above a threshold.
- Worker processes down.
Then you can react quickly before users start complaining that they are not receiving emails.
Common Pitfalls in Background Email Delivery
When building background email systems, watch out for some typical mistakes.
Pitfall 1: Sending Email Before Commit
If email sending is triggered before your database transaction commits, you can send emails that refer to data that does not exist.
Example:
- Start transaction.
- Insert user into database.
- Enqueue email job.
- Transaction fails and is rolled back.
- Worker sends email with a verification link that no longer works.
Better approach:
- Only enqueue email jobs after the transaction commits.
- Some ORMs or frameworks provide “after commit” hooks.
- Or you can store pending emails inside the transaction and enqueue them after a successful commit.
Pitfall 2: Missing Error Handling in Workers
If your worker loop does not handle exceptions, a single failing email can crash the worker, and no more emails are sent.
Always wrap job processing in a try/except that:
- Catches all exceptions.
- Logs the error.
- Decides whether to retry or send to dead letter.
- Continues with the next job.
Pitfall 3: Mixing Business Logic Inside the Worker
If your worker contains too much business logic, it becomes another “backend” to maintain.
Keep workers small:
- One function to fetch the job.
- One function to build the email.
- One function to send it.
- Clear separation between “deciding which email to send” and “sending emails.”
This makes the system easier to test and reason about.
Pitfall 4: Not Handling SMTP Limits
Many email providers have limits, for example:
- Maximum emails per minute.
- Maximum connections per second.
Your worker should respect these limits, for example:
- Use a rate limiter.
- Sleep between batches.
- Use provider-specific recommendations.
Background processing makes this easier because you can control how many workers you run and how fast they pull jobs.
Putting It All Together: Typical Flow
Here is a complete picture of how background email delivery often works in a backend application.
- User action
A user triggers an event that requires an email, for example user registration. - Application logic
Your API: - Validates input.
- Writes data to the database.
- Commits the transaction.
- Enqueue email job
After commit, your code: - Creates an email job object.
- Chooses a queue (for example
email_high). - Pushes the job into the queue.
- Immediate response
The API returns a success response to the user without waiting for the email. - Worker processing
In the background: - A worker reads a job from the queue.
- Optionally checks idempotency (has this job already been sent?).
- Builds the email content.
- Calls the email sending library to send it via SMTP or an email service API.
- Logs the result.
- Retries and errors
If sending fails: - The worker classifies the error as temporary or permanent.
- Temporary: increases the
attemptcount and requeues with exponential backoff. - Permanent or too many attempts: moves to dead letter queue and logs error.
- Monitoring
Metrics and logs let you observe: - How many emails are sent.
- How many fail.
- Whether the queue is growing.
This design keeps your user-facing API fast and improves reliability, while still delivering emails in a robust and controlled way.
Summary rule: Handle email as asynchronous jobs with clear job data, retries with exponential backoff, idempotency, and proper logging, instead of sending directly in user requests.
Views: 19
KAHIBARO