Background tasks patterns cheatsheet.

When to use a queue

Move out of request cycle:

  • Email / SMS / push.
  • Image / video processing.
  • LLM calls.
  • Third-party API calls that may be slow.
  • Scheduled jobs.
  • Batch operations.

Don’t move:

  • Simple DB writes.
  • Quick computations.
  • Sync user-facing operations that must complete.

Choosing

CeleryRQdramatiqdjango-tasks (Django 5.1+)
Maturityvery highhighhighnew
BrokersRedis, RabbitMQ, SQSRedisRedis, RabbitMQDB / Redis / custom
ScheduleCelery Beatrq-schedulerdramatiq.periodiqdjango-celery-beat-like
Complexityhighlowmediumvery low

Default: Celery for big projects, RQ for small.

RQ (simpler alternative)

uv add rq django-rq
INSTALLED_APPS = [..., "django_rq"]

RQ_QUEUES = {
    "default": {
        "HOST": "localhost",
        "PORT": 6379,
        "DB": 0,
        "DEFAULT_TIMEOUT": 360,
    },
}
# tasks.py
import django_rq

@django_rq.job
def send_email(to, body):
    ...

# Usage:
send_email.delay("[email protected]", "hi")
uv run python manage.py rqworker default

dramatiq

uv add dramatiq[redis] django-dramatiq
import dramatiq

@dramatiq.actor
def send_email(to, body):
    ...

send_email.send("[email protected]", "hi")

Simpler than Celery, automatic retries.

django-tasks (Django 5.1+)

from django.tasks import default_task_backend, task

@task
def send_email(to, body):
    ...

send_email.enqueue("[email protected]", "hi")

Pluggable backends (Redis, DB, etc).

Patterns

Fire and forget

def view(request):
    send_email.delay(user.email, "Welcome")
    return redirect("/")

Confirmation pattern

def view(request):
    task = process.delay(...)
    return render(request, "processing.html", {"task_id": task.id})

def status(request, task_id):
    result = AsyncResult(task_id)
    return JsonResponse({"state": result.state, "info": result.info})

Frontend polls /status/<id>/.

Chain workflow

from celery import chain

chain(
    fetch.s(url),
    parse.s(),
    save.s(),
)()

Fan-out / fan-in

from celery import group, chord

# Process items in parallel, then aggregate
chord(
    (process.s(item) for item in items),
    aggregate.s(),
)()

Periodic

# Celery Beat
CELERY_BEAT_SCHEDULE = {
    "daily-cleanup": {
        "task": "blog.tasks.cleanup",
        "schedule": crontab(hour=2),
    },
}

Cron-like with django-cron / django-q2

Alternative to beat.

Retries

@shared_task(
    autoretry_for=(IOError, TimeoutError),
    max_retries=5,
    retry_backoff=True,           # 1s, 2s, 4s, 8s, ...
    retry_backoff_max=600,
    retry_jitter=True,
)
def fetch_data():
    ...

Always cap retries. Exponential backoff with jitter prevents thundering herd.

Dead-letter queue

# After max retries
@shared_task(bind=True, max_retries=3)
def process(self, id):
    try:
        do(id)
    except Exception as e:
        try:
            raise self.retry(exc=e)
        except MaxRetriesExceededError:
            DeadLetter.objects.create(task="process", payload=id, error=str(e))

Idempotency

Tasks may run twice (at-least-once delivery):

@shared_task
def charge(payment_id, idempotency_key):
    if Charge.objects.filter(idempotency_key=idempotency_key).exists():
        return
    ...

transaction.on_commit

def view(request):
    with transaction.atomic():
        order = Order.objects.create(...)
        transaction.on_commit(lambda: process_order.delay(order.id))

Without on_commit, the worker may pick up the task before the row is committed.

Result handling

# Sync polling (don't do in views!)
result = task.delay(...)
result.get(timeout=30)

# Webhook pattern
@shared_task
def process(payload):
    result = compute(payload)
    notify_webhook(payload["callback_url"], result)

Monitoring

  • Flower (Celery): web UI for tasks.
  • Sentry: captures task exceptions.
  • Grafana / Prometheus: queue depth, latency.
import sentry_sdk
sentry_sdk.init(integrations=[CeleryIntegration(), DjangoIntegration()])

Task discovery

# tasks.py — autodiscovery looks here
@shared_task
def my_task(...): ...

Or explicit:

app.autodiscover_tasks(["blog", "accounts"])

Common mistakes

  • Long-running tasks without time_limit → workers stuck.
  • Importing models inside a task — fine; but importing huge libs delays startup.
  • Tasks doing 1 transaction with 1 task call (overhead) → batch.
  • Same task accepting Model instance — not serializable. Pass IDs.
  • No idempotency + at-least-once → duplicate side effects.

Read this next

If you want my Celery + RQ comparison templates, they’re at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .