DEV Community

Cover image for An Introduction to Asynchronous Tasks and Background Workers in Django
Michael Obasoro
Michael Obasoro

Posted on

An Introduction to Asynchronous Tasks and Background Workers in Django

Asynchronous tasks are operations that are carried out independently of the main program's flow. These operations are performed by simple processes called background workers.

If you didn't quite get that, don't worry. It'll make much more sense in a bit.

In this article, we will go through what asynchronous tasks and background workers are, why they're so important, and how to build a project that utilizes them with Django.

Who is this article for?

I welcome anyone interested in learning something new (or from a different perspective) to gain from my writing. However, I wrote this article with a specific set of people in mind, and they are:

  • Developers who have seen these terms before but never really got to understand what they meant
  • Django developers who want to learn how to use asynchronous tasks and background workers in their projects

Prerequisites

To get the most out of this tutorial, you'll need:

  • Basic Django knowledge (You know, project setup, models, views, templates, etc)
  • Basic understanding of the request-response cycle

ATTENTION: To get our hands dirty with asynchronous tasks, we're going to be relying on a distributed queuing system called Celery. Now while Celery technically runs on all major operating systems, there is no (longer) official support for Windows. This means some features like multiprocessing won't work out of the box.

If you're on Windows, I strongly urge you to consider using WSL (Windows Subsystem for Linux) for a smoother experience—as I will assume every reader on Windows is, from here onward. Head here for crystal-clear instructions on how to get it set up, then come back once you're done.

Synchronous Tasks vs Asynchronous Tasks

Before we unravel the secrets of asynchronous tasks, let's understand what it means for tasks to be synchronous using a simple example.

Take a look at this view function:

def example_view(request):
    do_something()
    do_another_thing()
    do_one_last_thing()
    return HttpResponse("Thank you for using our services, User!")
Enter fullscreen mode Exit fullscreen mode

In our example_view, each task is processed one after the other, from top to bottom, before displaying a thank you response to our visiting user. That means do_something is executed completely before do_another_thing is processed, and so on. That is, the tasks run synchronously, or in order.

Additionally, Django was originally built on the WSGI (Web Server Gateway Interface) standard. Don't worry if you're not familiar with that term and what it means. All you need to know (for now), is that Django was designed to handle one request at a time. It will only process request_2 after request_1 has been processed completely. In other words, Django is synchronous by default.

Okay. Nothing wrong here, right?

True, but let's take a closer look at the tasks in our view and give them a little more context.

In Django views, certain operations are typically carried out before returning a response to the client. Creating and updating objects using data from the request are a couple of the more common ones, but what about tasks that are less simple and can involve delays or heavy computation—such as sending emails or generating a PDF:

def example_view(request):
    if request.method == "POST":
        # This is our example_view "doing something" i.e creating a new Note object
        new_note_title = request.POST.get('title')
        Note.objects.create(title=new_note_title) 

        # ...and these are its other two tasks renamed to give additional context
        send_email_to_someone_somewhere()
        some_heavy_task_that_takes_30_seconds()

        # Return a response to the client once the tasks are complete
        return HttpResponse("Thank you for using our services, User!")
Enter fullscreen mode Exit fullscreen mode

We now have a clear idea of what happens in our view. A new note is created first, then the other two tasks are processed one after the other.

Here's the problem.

To send an email, your Django application has to communicate with an SMTP server or email service API. To generate a PDF, you'd typically work with a library that converts rendered HTML to a PDF document. The duration of each of these activities can range from a couple of seconds to a few minutes depending on certain factors. Some of these factors such as SMTP latency and the speed of the PDF library are beyond your control.

What this means for us is that Django will be stuck on a request to our example_view until every task within the view has been processed and completed:

def example_view(request):
    ...

    send_email_to_someone_somewhere() # Takes some time 
    some_heavy_task_that_takes_30_seconds() # Takes at least 30 seconds

    # The user probably leaves your web application dissatisfied before even seeing the appreciative text response
    return HttpResponse("Thank you for using our services, User!")
Enter fullscreen mode Exit fullscreen mode

To prevent scenarios where users have to wait for such operations to complete before making another request, we can convert them to asynchronous tasks, that is, they would run independently and out of the main program's flow. These asynchronous tasks are offloaded to be performed by simple processes called background workers.

What are Background Workers?

Background workers are separate processes or programs that run independently from the main application to perform tasks asynchronously. These workers handle long-running, resource-intensive or time-consuming jobs without blocking the main application's responsiveness.

When we offload these sort of tasks to background workers, our web applications can remain fast and responsive to user requests while ensuring that heavy operations are completed reliably, but in the background.

Put simply, our user wouldn't have to wait on a loading screen while the email sends or some heavy task that takes about 30 seconds:

def example_view(request):
    ...

    # Pretend these operations are now being performed by background workers
    send_email_to_someone_somewhere() # but asynchronously
    some_heavy_task_that_takes_30_seconds() # still takes 30 seconds, but asynchronously (doesn't block the request)

    # The user sees the response almost instantaneously
    return HttpResponse("Thank you for using our services, User!")
Enter fullscreen mode Exit fullscreen mode

How to use Asynchronous Tasks and Background Workers in Django

Now that you understand what asynchronous tasks and background workers are, it's time to figure out how to use them by building a small Django project.

Project Overview

We're going to build an app with an "About Me" form for users to fill. The form will contain fields for their name, bio, hobbies, etc. Our app will generate a PDF summary of their inputs and automatically send it to their email.

Project Setup

  • Create a new folder that will house our Django project. You can name it whatever you want to:
mkdir aboutme
Enter fullscreen mode Exit fullscreen mode
  • Move into the new directory and create a virtual environment:
cd aboutme
python3 -m venv myenv
Enter fullscreen mode Exit fullscreen mode
  • Now activate it:
source myenv/bin/activate
Enter fullscreen mode Exit fullscreen mode
  • Install Django and create a new project (we'll call it "core") within the current directory:
(myenv) pip install django
(myenv) django-admin startproject core .
Enter fullscreen mode Exit fullscreen mode

Now, your project structure should look something like this:

- aboutme/
  - manage.py
  - core/
    - __init__.py
    - asgi.py
    - settings.py
    - urls.py
    - wsgi.py
Enter fullscreen mode Exit fullscreen mode

For our project to handle asynchronous tasks, we're going to install Celery, a distributed task queue system, and Redis, to serve as its message broker.
To install the core Celery package and extra dependencies needed to use Redis as its message broker:

(myenv) pip install celery[redis]
Enter fullscreen mode Exit fullscreen mode

The last thing we need to install now is redis-server which runs the actual Redis database process locally:

# On Linux (Ubuntu)
sudo apt-get install redis-server
sudo service redis-server start # Start it

# On MacOS
brew install redis-server
brew services start redis # Start it
Enter fullscreen mode Exit fullscreen mode

To test if redis-server is working correctly, open a new terminal and start the Redis CLI:

# Opens the Redis CLI installed with redis-server
redis-cli
Enter fullscreen mode Exit fullscreen mode

Then type:

ping
Enter fullscreen mode Exit fullscreen mode

If everything works correctly, you should be replied with an enthusiastic:

PONG
Enter fullscreen mode Exit fullscreen mode

Now we're set!

Project Development

To get started, create a new Django application:

(myenv) python manage.py startapp myapp
Enter fullscreen mode Exit fullscreen mode

Now add it to INSTALLED_APPS in your settings.py file:

# settings.py

# ...

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'myapp' # Replace with your application's name if different
]

# ...
Enter fullscreen mode Exit fullscreen mode

Don't forget to tell Django where to check for any templates we'll be using:

# settings.py

# ...

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [BASE_DIR / 'templates'], # Add this line
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },

# ...
Enter fullscreen mode Exit fullscreen mode

In your app directory, create a templates directory. Within the newly-created templates directory, create another directory and name it whatever your app was named. This is what your folder structure should look like:

- aboutme/
  - manage.py
  - core/
    - __init__.py
    ...
  - myapp/
    - templates/
      - myapp/
Enter fullscreen mode Exit fullscreen mode

Now let's build the index page for our application.
Create an index.html file in the templates/myapp directory, then copy & paste the HTML code displayed below:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>About Me Form</title>
  <style>
    body {
      font-family: Arial, sans-serif;
      background: #f9f9f9;
      margin: 0;
      padding: 0;
      display: flex;
      align-items: center;
      justify-content: center;
      height: 100vh;
    }

    .form-container {
      background: white;
      padding: 2rem;
      border-radius: 10px;
      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
      width: 100%;
      max-width: 500px;
    }

    h2 {
      text-align: center;
      margin-bottom: 1.5rem;
      color: #333;
    }

    label {
      display: block;
      margin: 0.75rem 0 0.25rem;
      font-weight: bold;
    }

    input, textarea, select {
      width: 100%;
      padding: 0.6rem;
      border: 1px solid #ccc;
      border-radius: 5px;
      font-size: 1rem;
    }

    textarea {
      resize: none;
    }

    button {
      margin-top: 1.5rem;
      width: 100%;
      padding: 0.75rem;
      font-size: 1rem;
      color: white;
      background-color: #007bff;
      border: none;
      border-radius: 5px;
      cursor: pointer;
    }

    button:hover {
      background-color: #0056b3;
    }
  </style>
</head>
<body>
  <div class="form-container">
    <h2>Tell Us About You</h2>

    {% if messages %}
      <ul style="list-style: none; padding: 0;">
        {% for message in messages %}
          <li style="
            margin-bottom: 1rem;
            padding: 1rem;
            border-radius: 5px;
            color: white;
            background-color:
              {% if message.tags == 'success' %} #28a745
              {% elif message.tags == 'error' %} #dc3545
              {% elif message.tags == 'warning' %} #ffc107; color: black;
              {% else %} #007bff {% endif %};
          ">
            {{ message }}
          </li>
        {% endfor %}
      </ul>
    {% endif %}

    <form method="POST">
      {% csrf_token %}

      <label for="email">Email</label>
      <input type="email" name="email" id="email" required>

      <label for="name">Name</label>
      <input type="text" name="name" id="name" required>

      <label for="about">About Me</label>
      <textarea name="about" id="about" rows="4" required></textarea>

      <label for="hobby">Favorite Hobby</label>
      <input type="text" name="hobby" id="hobby" required>

      <label for="color">Favorite Color</label>
      <select name="color" id="color" required>
        <option value="">-- Select a color --</option>
        <option value="Red">Red</option>
        <option value="Blue">Blue</option>
        <option value="Green">Green</option>
        <option value="Yellow">Yellow</option>
        <option value="Purple">Purple</option>
        <option value="Black">Black</option>
        <option value="White">White</option>
      </select>

      <label for="saying">Favorite Saying</label>
      <input type="text" name="saying" id="saying">

      <label for="dream_job">Dream Job</label>
      <input type="text" name="dream_job" id="dream_job">

      <button type="submit">Generate My PDF</button>
    </form>
  </div>
</body>
</html>

Enter fullscreen mode Exit fullscreen mode

Now, to set up the index view.
In your views.py, copy & paste the lines of code displayed below:

# myapp/views.py

from django.shortcuts import render, redirect
from django.contrib import messages


def index(request):
    if request.method == 'POST':
        email = request.POST.get('email')
        name = request.POST.get('name')
        about = request.POST.get('about')
        hobby = request.POST.get('hobby')
        color = request.POST.get('color')
        saying = request.POST.get('saying')
        dream_job = request.POST.get('dream_job')

        data = {
            'email': email,
            'name': name,
            'about': about,
            'hobby': hobby,
            'color': color,
            'saying': saying,
            'dream_job': dream_job
        }

        # Nothing special happens yet. Just printing the form data
        print(data)

        messages.success(request, "Your summary is being generated and will be emailed shortly.")
        return redirect('index')

    return render(request, 'myapp/index.html') 

Enter fullscreen mode Exit fullscreen mode

Let's configure the URL routing. In your Django app's directory, create a urls.py file and set up the route to our index view:

# myapp/urls.py

from django.urls import path
from . import views 

urlpatterns = [
    path('', views.index, name='index'), 
]

Enter fullscreen mode Exit fullscreen mode

Now in your project's core urls.py, include your app's urls:

# core/urls.py

from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('myapp.urls')) # Add this line
]
Enter fullscreen mode Exit fullscreen mode

Now run the development server and check out what we've done so far:

(myenv) python manage.py runserver

# ...then visit http://localhost:8000 in your browser
Enter fullscreen mode Exit fullscreen mode

Perfect. We now have the basic backbone of our project: a form that collects data from users and sends it to our view.

It's time to handle actually generating the PDF and sending it to the user's email—asynchronously.

How Does Celery Work?

Celery is a task queue system that allows you to run operations asynchronously in the background, outside the main request-response cycle of your application.

When a task is triggered, it's packaged as a message and sent to a message broker (in our case, Redis)—instead of being executed immediately. This broker acts as a middleman, holding the task until a separate process, known as a Celery worker, retrieves it.

The worker continuously listens for new tasks, picks them up from the queue, and executes them independently.

Setting Up Celery

Before we can write any asynchronous tasks, we need to configure our project to use Celery. We've already installed Celery and its dependencies, we just need to tell our project how to work with it and vice-versa.

In the same directory as your settings.py (that is, the core directory), create a celery.py file and paste the following lines of code:

# core/celery.py

import os
from celery import Celery

# set the default Django settings module
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings")

app = Celery("core")

# Load task modules from all registered Django app configs.
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()

@app.task(bind=True)
def debug_task(self):
    print(f"Request: {self.request!r}")
Enter fullscreen mode Exit fullscreen mode

Then in the __init__.py file in the same directory, add the following lines to make sure Celery is loaded when Django starts:

# core/__init__.py

from .celery import app as celery_app

__all__ = ("celery_app",)

Enter fullscreen mode Exit fullscreen mode

Now we need to add Celery-related configurations to our settings.py file:

# core/settings.py

# Use Redis as the broker
CELERY_BROKER_URL = 'redis://localhost:6379/0'

# (Optional) Celery backend to store task results
CELERY_RESULT_BACKEND = 'redis://localhost:6379/0'
Enter fullscreen mode Exit fullscreen mode

Our Django project is now ready to work with Celery for queueing and processing asynchronous tasks.

If you found these configurations a bit confusing or overwhelming, that's perfectly fine. These are things that stick the more you use them. You are not expected to grasp it immediately or have it all stored in your head. The Celery docs are perfect for making references and going deeper.

What you need to know (for now) is that you will always make such configurations before your projects can use Celery.

Generating PDFs and Sending Emails Asynchronously

We want to define an asynchronous task that generates a PDF and sends it in an email to a user.

First, we need to install a package that handles the conversion of HTML to PDF. For this, we'll use WeasyPrint:

(myenv) pip install weasyprint
Enter fullscreen mode Exit fullscreen mode

Now we need to create the HTML template that will be converted to a PDF with the user's summary. In your templates/myapp directory create a pdf_template.html file and paste the following lines of code:

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>{{ name }}'s Personal Summary</title>
  <style>
    @page {
      margin: 2cm;
    }
    body {
      font-family: 'Georgia', serif;
      background: #f9f9f9;
      color: #2c3e50;
      padding: 2rem;
    }
    .container {
      background: white;
      border: 1px solid #ddd;
      border-radius: 10px;
      padding: 2rem;
      box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
    }
    h1 {
      text-align: center;
      color: #34495e;
      font-size: 28px;
      margin-bottom: 1.5rem;
    }
    .field {
      margin-bottom: 1.2rem;
    }
    .label {
      font-weight: bold;
      color: #555;
    }
    .value {
      margin-top: 0.2rem;
      font-size: 16px;
      line-height: 1.5;
      padding: 0.4rem 0.8rem;
      background: #ecf0f1;
      border-radius: 5px;
    }
    .quote {
      font-style: italic;
      color: #7f8c8d;
    }
    .footer {
      margin-top: 3rem;
      text-align: center;
      font-size: 12px;
      color: #aaa;
    }
  </style>
</head>
<body>
  <div class="container">
    <h1>{{ name }}'s Personal Summary</h1>

    <div class="field">
      <div class="label">About Me:</div>
      <div class="value">{{ about }}</div>
    </div>

    <div class="field">
      <div class="label">Favourite Hobby:</div>
      <div class="value">{{ hobby }}</div>
    </div>

    <div class="field">
      <div class="label">Favorite Color:</div>
      <div class="value">{{ color }}</div>
    </div>

    <div class="field">
      <div class="label">Favorite Saying:</div>
      <div class="value quote">"{{ saying }}"</div>
    </div>

    <div class="field">
      <div class="label">Dream Job:</div>
      <div class="value">{{ dream_job }}</div>
    </div>

    <div class="footer">
      Generated with ❤️ 
    </div>
  </div>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Now we need to write the actual task.

To define tasks in your project, create a tasks.py file in your app directory, then paste the following lines of code:

# myapp/tasks.py

from celery import shared_task # Special decorator used to define asynchronous tasks
from django.core.mail import EmailMessage
from django.template.loader import render_to_string
from weasyprint import HTML
import tempfile

@shared_task
def generate_and_send_pdf_summary(data):
    # Render HTML from template using user's data
    html_string = render_to_string('myapp/pdf_template.html', data)

    # Generate PDF using WeasyPrint
    with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as pdf_file:
        HTML(string=html_string).write_pdf(pdf_file.name)
        pdf_file.seek(0)
        pdf_content = pdf_file.read()

    # Send the PDF via email
    email = EmailMessage(
        subject="Your Personal Summary",
        body="Attached is your personalized summary.",
        to=[data['email']],
    )
    email.attach('summary.pdf', pdf_content, 'application/pdf')
    email.send()
Enter fullscreen mode Exit fullscreen mode

So we have an asynchronous task that can generate a PDF and send it to an email. Now we have to actually trigger it in our view.

Go back to your myapp/views.py and call the asynchronous task we just defined:

# myapp/views.py

# Import the task
from .tasks import generate_and_send_pdf_summary

def index(request):
    if request.method == 'POST':
        # ... shortened for brevity

        data = {
            'email': email,
            'name': name,
            'about': about,
            'hobby': hobby,
            'color': color,
            'saying': saying,
            'dream_job': dream_job
        }

        # Add the task to a queue so it runs in the background
        generate_and_send_pdf_summary.delay(data)

       # ...

    return render(request, 'myapp/index.html') 
Enter fullscreen mode Exit fullscreen mode

We're 99% there. One more thing.

Because we want our application to send emails to users, we have to configure our project's email settings.

Create a .env file in your project root directory and paste these lines with their appropriate values:

# aboutme/.env
# ALWAYS USE A .env file to store secrets even if you're not planning on committing to source control immediately (or at all). I think it's safer and good practice

EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = ''
EMAIL_PORT = 587
EMAIL_USE_TLS = True
EMAIL_HOST_USER = 'your-email@email.com'
EMAIL_HOST_PASSWORD = 'your-password'  
Enter fullscreen mode Exit fullscreen mode

Now install the python-dotenv package which helps our Django project load these environmental variables:

(myenv) pip install python-dotenv
Enter fullscreen mode Exit fullscreen mode

Then in the settings.py file, import the load_env function and call it:

# core/settings.py

from dotenv import load_dotenv
import os # You'll need this too
load_dotenv() # Now call it

# ...
Enter fullscreen mode Exit fullscreen mode

Lastly, add the email configurations to your settings:

# core/settings.py

# ...

# EMAIL
EMAIL_BACKEND='django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST=os.getenv('EMAIL_HOST')
EMAIL_PORT=os.getenv('EMAIL_PORT')
EMAIL_USE_TLS=True
EMAIL_HOST_USER=os.getenv('EMAIL_HOST_USER')
EMAIL_HOST_PASSWORD=os.getenv('EMAIL_HOST_PASSWORD')
Enter fullscreen mode Exit fullscreen mode

And we're done!

Running The Project

It's time to run our entire project.

Starting the Redis Server

Open up a new terminal and start the Redis server by typing redis-server (if it's not already running):

> redis-server
Enter fullscreen mode Exit fullscreen mode

If you got something like "Could not create server TCP listening socket *:6379: bind: Address already in use" as a response, that means the Redis server is already running—which is good news for us.

Starting the Celery worker

To start the Celery worker, open up a new terminal and use the following command from the root of your project (where manage.py is):

celery -A core worker --loglevel=info
Enter fullscreen mode Exit fullscreen mode

Now Celery is ready to handle asynchronous tasks from our application.

Trying out the Application

Make sure the Django development server is running, then check out what you've just created at http://localhost:8000.

Once you fill the form and click on the "Generate My PDF" button, you're immediately shown a message informing you that your PDF would be emailed to you shortly.

This may take a couple of seconds, but you don't have to watch the page load or wait for the process to complete. The app remains responsive, and you could even start filling the form, again. Whenever the task running in the background completes, you'll find your "About Me" summary (or summaries, if you eventually filled the form multiple times) in your email.

I hope this has helped demystify the concept of asynchronous tasks and background workers, and that you now feel more confident approaching them in Django.

If you have any questions, tips, or maybe need some help, please do not hesitate to leave a comment.

Thank you for reading!

Top comments (0)