BackgroundTasks in FastAPI is the inviting trap. It’s right there in the framework; it looks like a job queue. It isn’t. This post is when it’s enough and when you need a real queue.
What BackgroundTasks actually does
from fastapi import BackgroundTasks
@app.post("/signup")
async def signup(payload: SignUp, bg: BackgroundTasks):
user = await create_user(payload)
bg.add_task(send_welcome_email, user.id)
return user
After the response is sent, FastAPI runs the queued task in the same process. If the process dies between response and task running: task lost.
When BackgroundTasks is fine
- The task completes in < 100ms.
- Loss is acceptable (analytics ping, opportunistic cache warm).
- No retry needed.
- No coordination across processes.
A genuinely fire-and-forget call.
When you need a real queue
- The task takes seconds (image processing, ML inference, third-party API).
- Loss is not acceptable (welcome email, billing event).
- You need retries.
- The task should run on a worker pool, not your API process.
- You want scheduled / cron-like jobs.
For these: arq, Dramatiq, Taskiq, Celery .
Why this matters
A common bug pattern:
@app.post("/register")
async def register(payload, bg: BackgroundTasks):
user = await create_user(payload)
bg.add_task(send_welcome_email, user.id)
return user
User signs up. Process restarts (deploy, OOM, autoscaler). Email never sent. User confused. Support ticket.
With a real queue:
@app.post("/register")
async def register(payload):
user = await create_user(payload)
await arq_pool.enqueue_job("send_welcome_email", user.id)
return user
Job persists. Worker picks it up. Retries on failure. Email sent.
Migration path
- Pick a queue (arq for async-native ).
- Define the task in worker module.
- Replace
bg.add_task(fn, ...)withawait pool.enqueue_job("fn_name", ...). - Run a worker process alongside the API.
Hours of work. Permanent reliability win.
Hybrid pattern
For a service with mixed needs:
@app.post("/widget")
async def create_widget(payload, bg: BackgroundTasks):
widget = await save_widget(payload)
# OK to lose: cache warm
bg.add_task(warm_cache, widget.id)
# Must run: email + webhook
await arq_pool.enqueue_job("send_widget_email", widget.id)
await arq_pool.enqueue_job("fire_webhook", widget.id)
return widget
Use the right tool per task. Fire-and-forget for cheap; queue for important.
What to put in workers
Things that benefit from being out of the request path:
- Email sending.
- Webhook delivery.
- Image / video processing.
- LLM calls > 1 second.
- DB cleanup / aggregation.
- Periodic sync jobs.
- Anything that retries.
For LLM-specific patterns some calls stream to the user; others run async.
Common mistakes
1. BackgroundTasks for “this should always run”
It doesn’t. Migrate.
2. Async work that blocks
bg.add_task running a CPU-heavy task → API process stalls. CPU work goes to a separate worker pool.
3. No idempotency
Webhooks retry. Without idempotency , retries duplicate.
4. No DLQ
Tasks fail forever. No human notices. Every queue should have a dead-letter and review process.
5. No observability
You don’t know if tasks are running. Track per-task: queued, running, completed, failed. See LLM Observability — same patterns apply.
Read this next
- Background Jobs in Python — arq, Dramatiq, Taskiq
- Idempotency, Retries, and Exactly-Once Illusions
- Design a Distributed Task Queue
- FastAPI + Pydantic v2 + SQLAlchemy 2.0
If you want a FastAPI + arq + Postgres outbox 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 .