← Back to list

Django + Celery: Stop Making Users Wait

01 — The Problem

Moizsardar · 2026-05-25 07:58 · 0 claps · 7.0 min read
#django #django-rest-framework #backend-development #celery-django #api
Open on Medium ↗
Wiki topics: 🌐 · Web Development

Django + Celery: Stop Making Users Wait

01 — The Problem

Your users should never wait for your server’s side-effects

Imagine a user clicks “Send Invoice” on your Django app. Your view then: connects to an SMTP server, renders an HTML email, uploads an attachment to S3, logs the event, and sends a Slack notification. All of that might take 4–8 seconds. Meanwhile, the browser is showing a spinning circle. The user thinks the app is broken.

“Any work that doesn’t need to block the HTTP response should not run inside the HTTP request.”

That’s the entire reason Celery exists. It lets you say: “Django, just drop this task into a queue and respond to the user immediately. A worker process will pick it up and run it in the background.”

Comparison table

Comparison table

02 — The Architecture

Three pieces. One pipeline.

Before writing a single line of code, understand these three players:

1. Celery (the task producer + consumer framework)

A Python library you install. It defines tasks and knows how to send/receive them. Think of it as the postal service company — it handles the rules and protocols.

2. Redis (the broker — the message queue)

A fast in-memory database that stores tasks while they wait to be executed. Think of it as the physical post office sorting room — messages pile up here until a worker picks them up.

3. Celery Worker (the background process)

A separate running process (not your Django server) that continuously watches Redis and runs tasks. Think of it as the delivery driver who drives between the sorting room and the customer.

From request to execution — how Celery, Redis, and Django work together asynchronously

From request to execution — how Celery, Redis, and Django work together asynchronously

03 — The Full Journey

What happens when you call send_email.delay()

This is the most important mental model. Walk through it slowly.Step 1 — Serialization

Celery packages the task into a JSON message: task name, a unique ID (UUID), and its arguments. Python objects → plain text so Redis can store them.

Step 2 — Transportation (RPUSH to Redis)

The kombu library pushes this JSON payload onto a Redis list using an atomic RPUSH operation. O(1) time. Blazingly fast.

Step 3 — AsyncResult returned instantly

.delay() returns immediately with an AsyncResult object containing a task ID. The task has NOT run yet — it's only been queued.

Step 4 — Worker picks up the task

The Celery worker process is always running, watching Redis. It pops the message, deserializes it, acknowledges receipt (so the task isn’t lost if it crashes), and runs the function.

Step 5 — Result stored

The return value of your task function is stored back in Redis (the result backend) under the task’s UUID. You can fetch it anytime via result.get().

The Key Insight

.delay() only means "task was queued successfully." It does not mean the task ran. A worker crash after queuing means the task never executes — unless you have retry logic.

A simple breakdown of how Django, Redis, and Celery work together to execute background tasks asynchronously

A simple breakdown of how Django, Redis, and Celery work together to execute background tasks asynchronously

04 — The Code

Project setup, file by file

We’ll build a minimal Django project called dcelery with one app called newapp. Here's the structure:

dcelery/
├── dcelery/
│   ├── __init__.py       # ← most dangerous file, explained later
│   ├── celery.py         # ← Celery app definition
│   ├── settings.py
│   └── urls.py
├── newapp/
│   └── tasks.py          # ← your actual background tasks
├── docker-compose.yml
└── requirements.txt

requirements.txt

django>=4.2
celery>=5.3
redis>=5.0
django-celery-results  # optional: store results in Django DB

settings.py — Celery configuration

import os

# Read from environment so Docker can override - never hardcode localhost
CELERY_BROKER_URL = os.environ.get('CELERY_BROKER', 'redis://redis:6379/0')
CELERY_RESULT_BACKEND = os.environ.get('CELERY_BACKEND', 'redis://redis:6379/0')
# JSON is safer than pickle (no arbitrary code execution)
CELERY_TASK_SERIALIZER = 'json'
CELERY_RESULT_SERIALIZER = 'json'
CELERY_ACCEPT_CONTENT = ['json']
# Keep tasks in UTC - prevents timezone-related scheduling bugs
CELERY_TIMEZONE = 'UTC'
CELERY_ENABLE_UTC = True

dcelery/celery.py — the Celery app

import os
from celery import Celery

# Tells Celery which Django settings to load before anything else
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dcelery.settings')
# Create the Celery application instance
app = Celery('dcelery')
# namespace='CELERY' means: only read keys starting with CELERY_ from settings.py
app.config_from_object('django.conf:settings', namespace='CELERY')
# Scan all INSTALLED_APPS for tasks.py files automatically
app.autodiscover_tasks()

Critical Detail

The namespace='CELERY' argument is everything. Without it, Celery looks for a key called broker_url instead of CELERY_BROKER_URL, finds nothing, and silently falls back to RabbitMQ on localhost. This is the #1 source of the infamous pyamqp Connection Refused error.

05 — bind=True

What does bind=True actually mean?

In Python, when you write a class method, self refers to the object instance. bind=True does the same thing for Celery tasks — it makes self available inside your task function, giving you access to the task instance's metadata and methods.

Everything accessible via self when bind=True:

Common Mistake

You enabled bind=True but forgot to add self as the first parameter. Celery will inject the instance anyway, giving you: TypeError: task() takes 0 positional arguments but 1 was given.

06 — .delay() and AsyncResult

.delay() does NOT mean "task executed"

This is the single biggest misconception beginners have. Let’s make it concrete.

from newapp.tasks import send_email

result = send_email.delay(42)  # returns INSTANTLY - task queued, not run
print(result.id)       # '307864c2-6e82-...'  ← task UUID
print(result.status)   # 'PENDING' - worker hasn't picked it up yet
# Blocking call - waits until worker finishes and returns value
value = result.get(timeout=10)
print(result.status)   # 'SUCCESS' after worker completes

Think of it like ordering food delivery. .delay() is placing the order — you get a receipt (AsyncResult) immediately. But the food (task execution) arrives later.

Never call result.get() inside a task

Calling .get() inside another Celery task causes a deadlock — the outer task is blocking a worker thread waiting for the inner task, but there may be no free worker threads left to run it.

07 — init.py

The two-line file that breaks everything when missing

from .celery import app as celery_app  # force Celery init on Django startup

__all__ = ('celery_app',)  # declare public API of this package

Here’s the exact chain of events this file controls:

1. Without init.py

Django starts. Your celery.py is never imported. Celery's global current_app stays as the default uninitialized instance — using amqp://localhost:5672 (RabbitMQ). Every .delay() call hits a dead port and throws Connection Refused.

2. With init.py

Django starts → imports dcelery package → executes __init__.py → imports celery.py → Celery app initializes with your Redis config → all tasks route correctly.

08 — Docker

The networking trap everyone falls into

You set up Redis, start the worker, call .delay(), and get: Connection Refused. Your settings say localhost. This is the trap.

Inside Docker, every container has its own localhost. Your Django container's localhost is not your Redis container.

Docker Compose creates a private network between all your services. Each service is reachable by its service name — not localhost. Your redis: service is reachable at hostname redis inside any other container.

The full docker-compose.yml

version: '3.8'

services:
  redis:
    image: redis:latest
    ports: ["6379:6379"]
    healthcheck:                        # ensures redis is ready before Django starts
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
  django:
    build: ./dcelery
    command: python manage.py runserver 0.0.0.0:8000
    ports: ["8001:8000"]
    environment:
      - CELERY_BROKER=redis://redis:6379/0   # ← must match settings.py key
      - CELERY_BACKEND=redis://redis:6379/0
    depends_on:
      redis:
        condition: service_healthy       # waits for Redis healthcheck to pass
  celery:
    build: ./dcelery
    command: celery --app=dcelery worker -l INFO   # start worker
    environment:
      - CELERY_BROKER=redis://redis:6379/0
      - CELERY_BACKEND=redis://redis:6379/0
    depends_on:
      redis:
        condition: service_healthy

Environment variable naming must match

If your settings.py reads os.environ.get('CELERY_BROKER'), your docker-compose.yml must set CELERY_BROKER=, not REDIS_URL= or anything else. Mismatched names cause silent fallback to defaults — no error, wrong broker.

09 — Debug Checklist

When things break — and they will

The two most common failure symptoms are pyamqp Connection Refused and tasks queued but never executing. Here's the senior engineer's diagnostic chain:

Step 1 — Verify what broker Celery actually loaded

# Run this FIRST before anything else
from dcelery.celery import app as celery_app
print(celery_app.conf.broker_url)

# ✅ Good:  redis://redis:6379/0
# ❌ Bad:   amqp://guest@localhost//  ← __init__.py or namespace missing

Step 2 — Verify environment variables reached the container

docker exec -it django printenv | Select-String CELERY_BROKER
# Expected: CELERY_BROKER=redis://redis:6379/0

Step 3 — Verify Redis is reachable from inside the django container

docker exec -it django sh -c "redis-cli -h redis ping"
# Expected: PONG
# If: Connection refused → docker-compose network issue

Step 4 — Force fresh container state

docker-compose down
docker-compose up --build --force-recreate -d
# --force-recreate clears all cached env var states

Production Insight

Scaling to millions of users

  • Horizontal scaling: Run multiple Celery worker containers (just add more celery service replicas in Docker Compose or Kubernetes). Workers are stateless — add and remove freely.
  • Priority queues: Define separate queues (high, default, low) and route time-sensitive tasks (OTP delivery) to dedicated workers.
  • Rate limiting: Use @app.task(rate_limit='100/m') to prevent hammering external APIs.
  • Monitoring: Deploy Flower — a real-time web UI for inspecting tasks, workers, and queue depths.
  • Exponential backoff: Use countdown=2 ** self.request.retries in your retry logic — 1s, 2s, 4s, 8s — so transient failures don't hammer downstream services.
  • Redis persistence: Enable AOF (--appendonly yes) so tasks survive Redis restarts.

You now understand the full lifecycle — from .delay() to worker execution, from Docker networking to the __init__.py initialization trap. The next step is adding Celery Beat for scheduled periodic tasks — that's the next article in this series.


메타데이터
post_id
7e3a7967d81f
slug
django-celery-stop-making-users-wait-7e3a7967d81f
url
https://medium.com/@moizsardar056/django-celery-stop-making-users-wait-7e3a7967d81f
canonical_url
https://medium.com/@moizsardar056/django-celery-stop-making-users-wait-7e3a7967d81f
author_url
https://medium.com/@moizsardar056
status
ok
fetched_at
2026-06-09 15:37:30