Background Tasks with Celery
Every view discussed so far runs entirely within a single request/response cycle: a request comes in, the view does its work, and a response goes back — all before the browser sees anything. That works fine as long as "the view's work" is fast. It breaks down the moment a view needs to do something genuinely slow: sending a batch of reminder emails to everyone with a task due today, generating a large export, processing an uploaded file. Making a user's browser sit there waiting on that is a bad experience, and on some hosting setups, a request that takes too long gets killed outright. Celery exists to move that slow work out of the request/response cycle entirely.
The Architecture, End to End
Celery introduces a few new moving pieces working together:
| Piece | Role |
|---|---|
| Your Django app | Instead of doing slow work directly, it pushes a description of the work — "send this reminder email for task #42" — onto a queue. |
| The message broker | Typically Redis or RabbitMQ. Holds that queue of pending tasks, completely separate from your database. |
| Celery worker process(es) | One or more separate, long-running processes that watch the broker's queue, pick up tasks as they arrive, and actually run them — independently of any web request. |
The upshot: a view can hand off slow work almost instantly and return a response right away, while the actual work happens moments later, somewhere else entirely, in a worker process that has nothing to do with the request that triggered it.
Defining a Task with @shared_task
A Celery task is just a Python function, decorated so Celery knows it can be queued and run by a worker rather than called directly:
# taskflow/tasks.py
from celery import shared_task
from django.core.mail import send_mail
from .models import Task
@shared_task
def send_task_reminder_email(task_id):
task = Task.objects.get(id=task_id)
send_mail(
subject=f'Reminder: {task.title} is due soon',
message=f'Your task "{task.title}" is due on {task.due_date}.',
from_email='noreply@taskflow.example',
recipient_list=[task.project.owner.email],
)
Nothing here looks unusual — it's a plain function using Django's own email machinery. The
@shared_task decorator is what turns it into something Celery can hand to a worker
instead of running immediately wherever it's called.
Calling a Task Asynchronously with .delay()
A view never calls a Celery task like an ordinary function. Instead, it calls .delay() on
it — this queues the task on the broker and returns almost instantly, without waiting for the email to
actually be sent:
# taskflow/views.py
from .tasks import send_task_reminder_email
def mark_reminder_sent(request, task_id):
send_task_reminder_email.delay(task_id)
return redirect('task-list')
send_task_reminder_email.delay(task_id) doesn't run the function's body right there in the
view — it serializes the arguments, pushes them onto the broker's queue, and returns control back to
the view immediately. The actual email gets sent moments later, whenever a worker picks the task up —
entirely disconnected from how long the view itself took to respond.
Running a Celery Worker
This is the operational detail that trips people up most: installing Celery and writing
@shared_task functions does absolutely nothing on its own. Queued tasks just sit in the
broker, unprocessed, until a worker process is actually running to pick them up. That worker is a
separate, persistently-running process, started independently of runserver:
celery -A taskflow_project worker
-A taskflow_project points Celery at your project's Celery app configuration. In
development you'd run this in its own terminal window alongside python manage.py runserver;
in production it needs to be kept running and supervised the same way your web server process is —
restarted if it crashes, started automatically on deploy.
Scheduled Tasks with Celery Beat
Some work isn't triggered by a request at all — it just needs to happen periodically. A nightly job
that deletes completed tasks older than 90 days is a good TaskFlow example: nothing a user does directly
causes it, it just needs to run, say, every night at 2 AM. Celery Beat is Celery's
scheduler for exactly this — conceptually, it's Django's equivalent of a cron job, except it's
Python-native and calls the same @shared_task functions already defined for on-demand use.
This lesson won't walk through configuring Beat in full — just know it exists, and that it's the right
tool to reach for once "run this periodically" becomes a real requirement.
Is Celery Worth the Complexity?
Celery is genuinely useful, but it's not free — it means a broker to run, worker processes to keep alive and monitor, and one more thing that can fail independently of your web server. It's worth reaching for once you have real, unavoidable background or async work: email at scale, slow file processing, scheduled cleanup jobs. It is not something to reach for by default on day one, before TaskFlow actually has anything slow enough to justify offloading it.