FastAPI’s BackgroundTasks is the easy answer. It’s also the wrong answer if you care about reliability. This post is the practical taxonomy for picking a background-task system.

BackgroundTasks (built-in)

from fastapi import BackgroundTasks

@app.post("/signup")
async def signup(user: UserIn, bg: BackgroundTasks):
    db_user = await create_user(user)
    bg.add_task(send_welcome_email, db_user.email)
    return {"id": db_user.id}

After response is sent, FastAPI runs send_welcome_email. Same process, same memory, same lifecycle.

Use when: the task is short, idempotent, and you can tolerate occasional loss (worker crash, restart, deploy mid-task).

Don’t use when: the task must survive process death, retry on failure, or runs longer than a typical request.

ARQ — Redis-backed simplicity

from arq import create_pool
from arq.connections import RedisSettings

# tasks.py
async def send_email(ctx, to: str, subject: str, body: str):
    # ...

class WorkerSettings:
    functions = [send_email]
    redis_settings = RedisSettings(host="redis")

# main.py — enqueue
pool = await create_pool(RedisSettings(host="redis"))
await pool.enqueue_job("send_email", "[email protected]", "Hi", "Welcome")
arq tasks.WorkerSettings

ARQ:

  • Async-native (good FastAPI fit).
  • Redis only (one less moving part than Celery).
  • Cron-style scheduled tasks.
  • Retries, deferred jobs, job IDs.

Use when: you want Celery-like reliability without Celery’s complexity, and Redis is acceptable.

Dramatiq

import dramatiq
from dramatiq.brokers.redis import RedisBroker

broker = RedisBroker(host="redis")
dramatiq.set_broker(broker)

@dramatiq.actor(max_retries=3)
def process_upload(upload_id: int):
    # ...

# Enqueue
process_upload.send(upload_id)

Dramatiq:

  • Excellent ergonomics (cleaner API than Celery).
  • Redis or RabbitMQ.
  • Solid retry semantics.
  • Good middlewares (rate limit, retries, encryption).

Use when: you like Celery’s model but want a cleaner API.

Celery

from celery import shared_task

@shared_task(bind=True, autoretry_for=(Exception,), retry_backoff=True, max_retries=10)
def send_webhook(self, url, payload):
    requests.post(url, json=payload).raise_for_status()

Celery:

  • Mature, battle-tested.
  • Many features (chains, groups, beat, priority queues).
  • Wide integrations.
  • Heavier configuration.

Use when: you have an existing Celery setup, complex workflows, or need broad ecosystem support. See Django + Celery .

Decision matrix

NeedPick
Send email after requestBackgroundTasks
Webhook with retriesARQ / Dramatiq
Long ETL jobARQ / Dramatiq / Celery
Workflow with chainsCelery / Temporal
Massive scheduled jobsCelery + Beat
Mixed sync/async tasksCelery
All-async stackARQ
Durable workflowsTemporal

For a fresh FastAPI project today: ARQ is my default unless you need workflow primitives.

Reliability essentials (any system)

1. Idempotent tasks

async def charge_invoice(ctx, invoice_id: int):
    inv = await Invoice.get(invoice_id)
    if inv.charged:
        return  # already done — safe re-run
    # ...

At-least-once delivery means tasks WILL re-run occasionally. See Idempotency .

2. Pass IDs, not objects

# BAD
bg.add_task(send_email, user)  # serialization hazard, stale state

# GOOD
bg.add_task(send_email_by_id, user.id)

3. Bounded retries

Infinite retries amplify outages. Cap at ~10; dead-letter the rest.

4. Observable

Log every task start / finish / error with duration and ID. Wire to Prometheus / OTLP. See Observability .

5. Timeouts

Hung tasks tie up workers. Set both a soft and hard limit.

Long-running tasks

For tasks > 5 minutes:

  • Streaming progress to client: WebSocket / SSE. See FastAPI Streaming .
  • Polling endpoint: client polls /jobs/{id} for status.
  • Idempotent restart: if the worker dies, the next worker resumes from where it left off (checkpoint state).

For really long: workflow engines like Temporal — durable execution, automatic resumption.

Common mistakes

1. BackgroundTasks for things that matter

Worker restart kills them. Use a real queue.

2. No retry strategy

Network blip → task fails → never retried. Always plan for retries.

3. Synchronous code in async tasks

Blocks the event loop. Use asyncio.to_thread or sync workers.

4. Same DB connection for tasks and web

Long task holds a connection from the web pool; web requests starve. Use a separate worker pool.

5. Mixing concerns

Email + analytics + DB + image resize all in one task. Split: each concern is its own retryable unit.

What I’d ship today

For a new FastAPI app:

  • BackgroundTasks: only for trivial fire-and-forget after-response things.
  • ARQ: anything that should reliably run.
  • Celery: if you have an existing Celery shop or need the breadth.
  • Temporal: workflows with steps, compensation, durable state.

Read this next

If you want my FastAPI + ARQ starter, it’s 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 .