Abhishek Roka
Abhishek Roka

Why I Chose Celery for Password Reset Emails in Django


My password reset flow was working correctly.

The user could request a password reset, receive the email, click the link, and set a new password.

But while testing the feature, I noticed a different problem.

The API response was taking longer than it should.

The reason wasn't the password reset logic itself.

It was the email.

My Django view was responsible for performing the entire password reset email operation before returning a response to the user.

The flow looked roughly like this:

  1. Generate a secure password reset token.
  2. Prepare the frontend password reset URL.
  3. Load the email subject and HTML body from templates.
  4. Connect to the SMTP server.
  5. Send the email.
  6. Wait for the SMTP server to respond.
  7. Finally return the API response.

The problem becomes obvious when looking at the request lifecycle.

The user doesn't actually need to wait for the email server.

They only need confirmation that the password reset request has been accepted.

Yet my API was making the user wait for an external I/O operation to complete.


Why Synchronous Email Sending Is a Problem

Email delivery is an external operation.

My Django application has no control over how quickly the SMTP server responds.

If the mail server responds quickly, everything feels fine.

But if there is network latency or the SMTP server takes longer to respond, the Django request takes longer as well.

This creates an unnecessary dependency inside the request-response cycle.

The user is effectively waiting for:

User
  ↓
Django API
  ↓
Generate Token
  ↓
Prepare Email
  ↓
SMTP Server
  ↓
Wait for Response
  ↓
Django API Response
  ↓
User

The password reset API is now dependent on the email server's response time.

That isn't ideal.

The email should be delivered, but the user shouldn't have to wait for the delivery process to finish before receiving the API response.


Should I Make Django Asynchronous?

My first thought was to consider asynchronous programming.

Django supports asynchronous views through ASGI, allowing developers to work with async and await.

This is useful for handling I/O-bound workloads and can improve how an application manages concurrent operations.

But it made me ask a more important architectural question:

Do I really need to change my application's runtime behaviour just to send an email?

The problem wasn't that the Django application couldn't perform the email operation asynchronously.

The problem was that the email operation didn't need to be part of the request at all.

Even if I made the email functionality asynchronous, the request would still have to coordinate with that operation if I wanted to wait for it before returning the final response.

What I actually wanted was different.

I wanted the API to say:

"I've accepted your password reset request."

And then let another process handle the email.

That led me to background task processing.


Moving the Email Into a Background Task

Instead of sending the email directly from the Django request, I can delegate the operation to a background task.

The architecture then becomes:

User
  ↓
Django API
  ↓
Generate Token
  ↓
Queue Email Task
  ↓
Return Response
  │
  └──────────────→ Celery Worker
                       ↓
                    Send Email
                       ↓
                   SMTP Server

Now the request-response cycle doesn't need to wait for the SMTP server.

The Django API can return much sooner while the email is processed separately.

This is where Celery becomes useful.


Why I Chose Celery

Celery is a distributed task queue that can be used to execute background jobs outside the normal Django request-response cycle.

For my use case, this means Django can create a task for sending the password reset email and hand that task over to a Celery worker.

The worker then processes the task independently.

This gives me several advantages.

Faster User Experience

The biggest improvement is that the user no longer has to wait for the SMTP server before receiving an API response.

The request can finish while the email is being processed in the background.

Better Separation of Responsibilities

The Django API handles the password reset request.

The Celery worker handles the background email operation.

Each component has a more focused responsibility.

Better Reliability

The background task is processed by a separate worker process rather than being executed directly inside the request.

This means the email-processing workload isn't tied directly to the lifetime of the HTTP request.

Of course, background processing doesn't automatically guarantee delivery. Tasks can still fail and need proper retry and monitoring strategies.

But architecturally, the email operation is no longer unnecessarily coupled to the API response.


Understanding the Celery Architecture

Once I introduced Celery, the application architecture became more than just Django and PostgreSQL.

The background-processing setup introduced several components.

1. Celery Worker

The Celery Worker is responsible for actually executing background tasks.

Django places the task into a queue, and the worker picks it up for processing.

For my current use case, the worker handles the password reset email.

Conceptually:

Django
  ↓
Task Queue
  ↓
Celery Worker
  ↓
Send Password Reset Email

The important part is that the worker operates separately from the HTTP request.


2. Flower

Once background jobs are introduced, another question appears:

How do I know what is happening with my tasks?

This is where Flower becomes useful.

Flower provides a monitoring interface for Celery.

It allows me to inspect things such as:

This becomes particularly valuable when debugging.

With synchronous email sending, I could see the request failing directly.

With background tasks, the API request can succeed while the background task fails later.

A monitoring system therefore becomes much more important.


3. Celery Beat

Celery Beat is used for scheduled tasks.

It can periodically trigger tasks based on a defined schedule.

For example:

I don't currently need Celery Beat for the password reset email itself.

But it becomes useful as the application grows and starts requiring scheduled background work.

This is why I included it as part of the broader Celery architecture even though it isn't directly involved in the password reset flow.


The Important Architectural Distinction

This implementation also helped me understand an important distinction between asynchronous programming and background task processing.

They can solve related problems, but they aren't the same architectural decision.

Asynchronous programming allows a process to efficiently handle I/O-bound operations without blocking in the traditional way.

Background task processing moves work outside the request-response lifecycle entirely.

For my password reset email, the second approach made more sense.

I didn't need the Django request to efficiently wait for an email operation.

I needed the Django request to stop waiting for it altogether.

That distinction changed the architecture I chose.


Final Thoughts

One thing I'm learning while building this Placement Portal is that introducing a more complex architecture isn't automatically the right solution.

Sometimes the better question isn't:

"How can I make this operation asynchronous?"

It's:

"Does this operation belong in the request-response cycle at all?"

In my case, sending an email didn't need to be part of that cycle.

By moving password reset email delivery to Celery background tasks, I can keep my Django application synchronous while separating the slower external operation from the API request.

The result is:

I didn't need to redesign my entire Django application.

I just needed to put the work in the right place.

That's probably one of the more important architectural lessons I've learned while building this Placement Portal in public.