skip to content

A Django signup view calls send_mail() and sometimes hangs for minutes when the SMTP relay is slow — why, and how would you fix it?

level: seniorimportance: should knowfreq 44%

answer

  1. nothing in core.mail is async
  2. timeout option defaults to None
  3. bound it, then move it
  4. ImmediateBackend is the TASKS default

basics

~20 s

Django's email backends are synchronous, so send_mail() holds the request while it connects and talks SMTP, and the SMTP backend has no timeout unless you set one. Set the mailer's timeout, then move sending off the request into a task run by a real worker.

solid answer

~40 s

`django.core.mail` has no async API. `send_mail()` blocks the worker for DNS, the TCP connect, the TLS handshake and every SMTP exchange. The SMTP backend's `timeout` option defaults to `None`, which falls back to `socket.getdefaulttimeout()`, normally no timeout, so a stalled relay pins the worker indefinitely. First bound it: `"timeout": 10` in the mailer's `OPTIONS` (or `EMAIL_TIMEOUT` on the deprecated settings). Then take the send out of the request by wrapping it in a task and enqueueing it once the signup transaction commits. With Django 6.0's `django.tasks`, the default `TASKS` backend is `ImmediateBackend`, which runs the task inline. So you also need a backend with an external worker, or Celery. Don't hide it with `fail_silently=True`: that is deprecated in 6.1 and doesn't fix a hang anyway.

code

python · 22 lines
python
# accounts/tasks.py
from django.contrib.auth import get_user_model
from django.core.mail import send_mail
from django.tasks import task


@task
def send_welcome_email(user_id):
    user = get_user_model().objects.get(pk=user_id)
    send_mail("Welcome aboard", f"Hi {user.first_name}!", None, [user.email])


# accounts/views.py
from django.db import transaction


def signup(request):
    ...
    with transaction.atomic():
        user = form.save()
        transaction.on_commit(lambda: send_welcome_email.enqueue(user.pk))
    return redirect("signup-done")

go deeper

for a junior

Recall that send_mail() waits for the mail server before the view can return a response.

for a middle

Explain the SMTP backend's timeout option and why its None default means an unbounded wait.

for a senior

Bound the send, move it to a worker, enqueue after commit with a primary key, and recognise that ImmediateBackend is not background execution.

for a principal

Decide which emails must be sent synchronously for user feedback and which go through a queue, and own the retry and resend policy.

## Why the view hangs Everything in `django.core.mail` is **synchronous**. There is no `asend_mail()` and no async backend method. When a view calls `send_mail()`, the worker process or thread handling that request runs the whole delivery before it can return a response: 1. resolve the relay's hostname and open a TCP connection; 2. negotiate TLS (`use_tls` upgrades with STARTTLS, `use_ssl` wraps the socket from the start); 3. authenticate with `username`/`password`; 4. send the envelope and message, then close the connection. Every step waits on the network. The SMTP backend's **`timeout`** option defaults to `None`. Django then passes no timeout to `smtplib`, so the connection uses `socket.getdefaulttimeout()`, which is normally `None`, meaning **no timeout**. A relay that accepts the TCP connection and then stalls can hold the request until the client or proxy gives up. Under load, a few stuck requests use up the worker pool and the whole site slows down, not just signup. In an **async view** the damage is worse, because a blocking call inside the event loop stalls every coroutine sharing it. ## Step one: bound it Whatever else you do, set a timeout on the mailer: - Django 6.1 with `MAILERS`: `"OPTIONS": {"host": ..., "timeout": 10}`. - Deprecated settings: `EMAIL_TIMEOUT = 10`. A slow relay then raises an exception within a known time instead of hanging. The view can report "we'll resend the confirmation" instead of timing out at the proxy. ## Step two: take it off the request | Option | What happens in the request | Trade-off | |---|---|---| | Inline `send_mail()` with a timeout | still waits up to `timeout` | simplest; latency and failures still reach the user | | `django.tasks` task (6.0+) with a worker-backed backend | only enqueues | needs a third-party backend and worker process; built-ins are dev and test only | | `django.tasks` with the default `ImmediateBackend` | **runs the task inline** | looks asynchronous in code but is not | | Celery task | only enqueues | broker and worker to operate | | Third-party email backend that queues | only queues | queueing lives inside the mail layer | The subtle one is the default `TASKS` setting, `{"default": {"BACKEND": "django.tasks.backends.immediate.ImmediateBackend"}}`. That backend executes the task immediately in the calling process. Decorating a function with `@task` and calling `.enqueue()` does **not** move work off the request until you configure a backend with a real worker. Django ships none. ## Where the enqueue belongs Signup usually creates the user inside a transaction. Two rules follow, both owned in detail by other topics: - Enqueue **after the transaction commits**, not inside it, so a rolled-back signup never sends a welcome email and the worker never looks up a user row it can't see yet. - Pass the task the user's **primary key**, not the model instance. The worker loads fresh data, and the argument stays serializable. ## Failure handling without `fail_silently` `fail_silently=True` looks like a fix but isn't: - it is **deprecated** in 6.1 and removed in 7.0; - on the SMTP backend it only swallows `OSError` while connecting and `smtplib.SMTPException` while talking. It does not make a stalled connection return sooner; - it hides configuration mistakes, such as a wrong host or bad credentials, as well as transient glitches. Once the send runs in a worker, **retries** become the recovery mechanism. Your task framework handles that, and it is the reason the send should be idempotent per signup. Log the failure with enough context, the user id and the mailer alias, for someone to resend. ## How it shows up in production The symptoms rarely point at email directly: - signup (or password reset) has a latency tail far above other endpoints, while the database looks healthy; - the reverse proxy logs gateway timeouts on that one route, and retries sometimes create duplicate accounts because the first request actually succeeded; - worker utilisation climbs at the same moment the mail provider has an incident; - a thread dump or stack sample shows workers sitting in `smtplib` or socket reads. A useful diagnostic is to point the mailer at the console backend in a staging copy. If the latency disappears, SMTP was the cost. Measuring how long `send_mail()` takes around the call gives the same answer in production. ## Putting it together 1. Add `"timeout"` to the transactional mailer's `OPTIONS`. 2. Move the send into a task that takes `user_id` and renders the message itself. 3. Enqueue it after commit. 4. Configure a task backend that runs a real worker, or use Celery. 5. Remove any `fail_silently=True` and let the worker's retry policy deal with transient failures.

  • Why doesn't wrapping the send in a django.tasks task help on a fresh Django 6.1 project?
    The default `TASKS` setting uses `django.tasks.backends.immediate.ImmediateBackend`, which runs the task synchronously when `enqueue()` is called, so the request still waits for SMTP. Django ships no worker. Moving work off the request needs a third-party task backend with an external worker process, or Celery. The built-in backends are for development and tests.
  • The view is async def. What changes about calling send_mail() there?
    `send_mail()` blocks, and Django's mail API has no async variant, so calling it directly inside an async view stalls the event loop for every coroutine it serves. At minimum wrap it with `asgiref.sync.sync_to_async`. That moves it to a thread but still makes the response wait. Enqueueing it for a worker is still the real fix.

A shop clerk who walks each customer's letter to the post office before serving the next person in line: with no time limit, one slow post office stalls the whole queue. Handing letters to a courier fixes it, but only if the courier is a different person. Django's default ImmediateBackend is the clerk walking there anyway.

saying these in an interview costs you the question

  • The SMTP backend times out after a few seconds by default.
  • fail_silently=True stops a slow relay from blocking the request.
  • Decorating the function with @task is enough to make it run in the background.
  • send_mail() is non-blocking because the relay queues the message.
  • Sending the email inside the atomic block is fine because save() already ran.