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
| Need | Pick |
|---|---|
| Send email after request | BackgroundTasks |
| Webhook with retries | ARQ / Dramatiq |
| Long ETL job | ARQ / Dramatiq / Celery |
| Workflow with chains | Celery / Temporal |
| Massive scheduled jobs | Celery + Beat |
| Mixed sync/async tasks | Celery |
| All-async stack | ARQ |
| Durable workflows | Temporal |
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
- FastAPI + Pydantic v2 + SQLAlchemy 2.0
- Django + Celery 2026
- Idempotency, Retries, and Exactly-Once Illusions
- Temporal Workflow Engine 2026
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 .