Send Django Email in the Background

Send Django email outside the request cycle: queue emails with Celery after commit, retry provider failures with backoff, use django-celery-email or a custom backend, keep templates rendered in the task, and avoid duplicate sends.

Problem statement

Calling send_mail inside a view makes the user wait for the email provider: usually 100-500 ms, sometimes seconds, and when the provider or SMTP server is down, the signup or checkout itself fails with a 500. Email is a side effect that can happen a moment later. Moving it to a background task keeps requests fast and makes delivery resilient with retries, as long as tasks are queued after the transaction commits and are safe to retry.

Email off the request pathView saves datafast responseon_commit queuesemail taskCelery workerrenders and sendsRetry with backoffprovider errorsThe request finishes immediately; the worker sends and retries if the provider fails.
  1. Write a Celery task that takes IDs, renders the template, and sends the email.
  2. Queue it with transaction.on_commit(lambda: send_welcome.delay(user.pk)).
  3. Retry transient failures: autoretry_for=(SMTPException, AnymailAPIError, ConnectionError), retry_backoff=True, max_retries=5.
  4. Guard against duplicates with a sent flag or an idempotency record.
  5. For existing code that calls send_mail everywhere, use a queued email backend (django-celery-email) as a stepping stone.
  6. Route email tasks to their own queue so slow reports never delay them.

Step-by-step solution

1. An email task

# accounts/tasks.py
from smtplib import SMTPException
from celery import shared_task
from django.contrib.auth import get_user_model
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string

@shared_task(bind=True, autoretry_for=(SMTPException, ConnectionError, TimeoutError),
             retry_backoff=True, retry_backoff_max=600, max_retries=6, acks_late=True)
def send_welcome(self, user_id):
    user = get_user_model().objects.get(pk=user_id)
    ctx = {"user": user}
    msg = EmailMultiAlternatives(
        subject="Welcome to Example",
        body=render_to_string("emails/welcome.txt", ctx),
        to=[user.email],
    )
    msg.attach_alternative(render_to_string("emails/welcome.html", ctx), "text/html")
    msg.send()

Pass primary keys and render inside the task, so the email reflects committed data and arguments stay small and serialisable. With Anymail, add anymail.exceptions.AnymailAPIError to autoretry_for.

2. Queue after commit

from django.db import transaction

def signup(request):
    user = create_user(request.POST)
    transaction.on_commit(lambda: send_welcome.delay(user.pk))
    return redirect("dashboard")

Queuing before commit lets the worker run before the user row exists (DoesNotExist), or send an email for a signup that was rolled back.

signup request duration (illustrative)send_mail in view, healthy450 mssend_mail in view, slow provider1150 msqueued task (on_commit)80 ms0 ms600 ms1200 msQueuing takes milliseconds; the provider's latency moves to the workerdjango-deployment.com
Illustrative figures: the bigger win is that provider outages no longer break signups.

3. Avoid duplicate sends

Retries and at-least-once delivery mean a task can run twice. For one-time emails, record that the email was sent:

@shared_task(bind=True, autoretry_for=(SMTPException,), retry_backoff=True)
def send_receipt(self, order_id):
    with transaction.atomic():
        order = Order.objects.select_for_update().get(pk=order_id)
        if order.receipt_sent_at:
            return
        build_receipt_email(order).send()
        order.receipt_sent_at = timezone.now()
        order.save(update_fields=["receipt_sent_at"])

A crash between send() and save() can still cause one duplicate; that is usually acceptable for email, unlike payments.

4. Queue existing send_mail calls

If the codebase calls send_mail in many places, a Celery-backed email backend queues every message without touching call sites:

EMAIL_BACKEND = "djcelery_email.backends.CeleryEmailBackend"
CELERY_EMAIL_BACKEND = "anymail.backends.postmark.EmailBackend"

This is a quick win, but it queues already-rendered messages and does not wait for commit; migrate important emails to explicit tasks over time.

5. Give email its own queue

CELERY_TASK_ROUTES = {"accounts.tasks.send_*": {"queue": "email"}, "orders.tasks.send_*": {"queue": "email"}}

Run a small worker for -Q email so a backlog of report jobs never delays password reset emails.

background email done rightTask takes IDs, renders insidefresh, committed dataQueued with on_commitno email for rolled-back workRetries with backoff on transient errorsprovider outages surviveSent flag for one-time emailslimits duplicatesDedicated email queuenever behind slow jobsPassword resets and receipts should not wait behind batch workdjango-deployment.com
Monitor the email queue length; a growing queue means a stuck or missing worker.

Verification check: sign up in staging and confirm the response is fast and the welcome email arrives within seconds; stop the email provider (or point it at an invalid host) and confirm tasks retry and succeed once it returns.

Explanation

Django's email API is synchronous: send() opens a connection to SMTP or an HTTP API and waits. A Celery task moves that wait to a worker process and adds retry semantics, turning a transient provider failure into a delayed email instead of a failed request. transaction.on_commit ensures the task only exists if the data it describes was saved. Because Celery delivers at least once, one-time emails benefit from a small idempotency check.

Edge cases / notes

  • Password reset emails from PasswordResetView call send_mail directly; subclass the form's send_mail to queue a task, or use a queued backend.
  • Large attachments should be stored and referenced by ID, not passed as task arguments.
  • Locale. Activate the user's language in the task (translation.override(user.language)) before rendering.
  • Testing. With CELERY_TASK_ALWAYS_EAGER and the locmem backend, tests can assert on mail.outbox.

Next steps

FAQ

How do I send Django email asynchronously?

Send it from a Celery task queued with transaction.on_commit, passing IDs and rendering inside the task.

What if the email provider is down?

The task retries with backoff, so the email is delayed rather than lost, and the user's request still succeeds.

Can I make all send_mail calls asynchronous at once?

Yes, with a Celery-backed email backend such as django-celery-email, as a first step.

Will retries send duplicate emails?

Rarely, but possible. Record a sent flag for one-time emails such as receipts.

Keep going