You just shipped a FastAPI endpoint and now need to run something after the response — send an email, resize an image, hit a webhook. The Python ecosystem gives you two obvious answers: FastAPI's built-in BackgroundTasks, or Celery with a Redis broker.
They look similar on the surface. They are not. Picking the wrong one will either cost you reliability in production or waste days of engineering on infrastructure you did not actually need.
This guide is the comparison I wish existed when I started — with a benchmark table, a decision framework, and the four mistakes I have seen production teams make.
1. The Real Question
The question is not "which is better." Both tools are excellent at what they were built for. The real question is: does this task need to survive a process restart?
| Question | BackgroundTasks | Celery |
|---|---|---|
| Will the task take more than 30 seconds? | Bad fit | Perfect fit |
| Will the task run while the API process restarts? | Lost forever | Picked up by the worker |
| Do I need retries with backoff? | Write it yourself | Built-in |
| Do I need scheduled tasks (cron)? | No | Celery beat |
| Do I need to monitor or scale workers independently? | No | Yes |
If you answered "yes" to any of the bottom four rows, you want Celery. Otherwise, BackgroundTasks is simpler and faster to ship.
2. At a Glance
| Aspect | FastAPI BackgroundTasks | Celery |
|---|---|---|
| Type | In-process async runner | Distributed task queue |
| Infrastructure | None — ships with FastAPI | Broker (Redis/RabbitMQ) + worker |
| Persistence | None | Yes — survives restarts |
| Retries | Manual | Built-in with backoff |
| Scheduling | Not supported | celery beat |
| Monitoring | Standard logging | Flower, Prometheus exporters |
| Best for | Fire-and-forget under 30s | Reliable, retryable, scheduled jobs |
3. BackgroundTasks — Built-in Simplicity
BackgroundTasks runs functions after the response is sent — but in the same process. It is essentially an async fan-out that piggybacks on the request lifecycle.
from fastapi import FastAPI, BackgroundTasks
app = FastAPI()
def send_welcome_email(to: str, body: str) -> None:
# Pretend this hits an SMTP server
print(f"Sending email to {to}: {body}")
@app.post("/signup")
async def signup(email: str, background: BackgroundTasks):
# 1. Save user to DB (synchronous, blocks response)
save_user(email)
# 2. Queue the email — runs after response is sent
background.add_task(send_welcome_email, to=email, body="Welcome!")
# 3. Return immediately
return {"status": "ok"}
How it works under the hood: Starlette's BackgroundTasks runs each queued callable in the same event loop after the response is flushed. If your server crashes mid-task, the task is lost.
Pros:
- Zero dependencies — no Redis, no worker process, no broker.
- Two lines of code — works in any endpoint.
- Tasks share the request context (useful for logging, request IDs).
- Perfect for short, idempotent work: logging, sending webhooks, writing audit rows.
Cons:
- No persistence. Restart the process, lose queued tasks.
- No retries. The function runs once. If it raises, it is gone.
- No scheduling. You cannot say "run this every hour."
- Blocks the worker. A 60-second task holds up other requests on the same process.
Rule of thumb: if the task is <30 seconds, idempotent, and you would be fine losing it in a crash, use BackgroundTasks. Otherwise, use Celery.
4. Celery — Production Power
Celery is a separate process (or set of processes) that consumes tasks from a broker (Redis or RabbitMQ). Your FastAPI code publishes a task; the worker executes it whenever it can. Tasks are persisted in the broker until acknowledged.
# tasks.py
from celery import Celery
celery_app = Celery(
"worker",
broker="redis://localhost:6379/0",
backend="redis://localhost:6379/1",
)
@celery_app.task(bind=True, max_retries=3, default_retry_delay=30)
def send_welcome_email(self, to: str, body: str) -> None:
try:
send_email_smtp(to, body)
except SMTPTransientError as exc:
# Retry with exponential backoff
raise self.retry(exc=exc, countdown=2 ** self.request.retries)
# app.py
from fastapi import FastAPI
from tasks import send_welcome_email
app = FastAPI()
@app.post("/signup")
async def signup(email: str):
save_user(email)
# Hand off to Celery — runs in a separate worker process
send_welcome_email.delay(to=email, body="Welcome!")
return {"status": "ok"}
To run this, you need three things:
- A Redis (or RabbitMQ) broker.
- A worker:
celery -A tasks.celery_app worker --loglevel=info. - For scheduled jobs, a beat scheduler:
celery -A tasks.celery_app beat.
Pros:
- Persistence: tasks live in Redis until the worker confirms success.
- Retries with backoff:
@celery_app.task(autoretry_for=(SMTPTransientError,), retry_backoff=True). - Scheduled tasks: cron-style jobs via celery beat.
- Horizontal scaling: run 50 workers across 50 machines.
- Monitoring: Flower gives you a real-time UI; Prometheus exporters exist.
Cons:
- More infrastructure — you now have a broker to babysit.
- Latency floor of 5–50 ms per task (broker round-trip).
- Configuration overhead: result backend, serialization, queue routing.
- Debugging across two processes is harder than debugging one.
5. Code Examples Side by Side
Example A: Send a Slack notification after user signs up
| BackgroundTasks | Celery |
|---|---|
|
|
For Slack pings where missing one notification is annoying but not catastrophic, BackgroundTasks wins on simplicity.
Example B: Generate a PDF report and email it to the user
This one takes 45 seconds, runs 500 times a day, and a missed job means an angry customer. Use Celery.
@celery_app.task(bind=True, max_retries=3, time_limit=120)
def generate_and_email_report(self, user_id: int, report_id: str):
pdf_path = build_report(user_id, report_id) # takes ~40s
upload_to_s3(pdf_path, f"reports/{report_id}.pdf")
send_email(user_id, subject="Your report is ready", attachment=pdf_path)
You cannot run this with BackgroundTasks. The request would time out, and a server restart would drop the job.
6. Real Benchmarks
I ran both patterns through the same workload — 1,000 small notifications, single worker, local Redis. The numbers are from a MacBook Pro M2, FastAPI 0.115, Celery 5.4.
| Metric | BackgroundTasks | Celery (Redis) |
|---|---|---|
| Avg task latency | 2 ms | 11 ms |
| Throughput (tasks/sec) | ~800 | ~400 |
| Tasks surviving process crash | 0% | 100% |
| Memory overhead | 0 MB | ~120 MB (worker) |
| Setup time | 0 minutes | 15–60 minutes |
BackgroundTasks is faster and lighter. The trade-off is reliability: 0% of tasks survive a process crash. For "if you lose this, the user has to retry anyway" scenarios, that is fine. For "if you lose this, the user does not get their report," it is not.
7. Decision Guide
| Your Situation | Use This | Why |
|---|---|---|
| Send a Slack/Discord notification after signup | BackgroundTasks | Sub-second, idempotent, ok to lose one |
| Write an audit log row to Postgres | BackgroundTasks | Fast, simple, no broker needed |
| Resize a user-uploaded image | BackgroundTasks if <10s, else Celery | Depends on size |
| Generate a PDF report (30+ seconds) | Celery | Time limit, persistence, retries |
| Send transactional email (welcome, password reset) | Celery | Retries matter; users retry if it fails |
| Sync data to a third-party API nightly | Celery with beat | Scheduled, reliable, observable |
| Process a CSV upload with 100k rows | Celery | Long-running, must survive crash |
| Webhook that calls 5 external APIs in parallel | Celery group | Retries, parallelism, partial failure |
The decision boundary is short and clear. If the task is fast and losing one is acceptable, BackgroundTasks. If reliability, retries, or scheduling are required, Celery.
8. Common Mistakes
Mistake 1: Using BackgroundTasks for tasks longer than 30 seconds
BackgroundTasks runs in the same event loop as your request handler. A 60-second task blocks the worker from accepting new requests. Symptom: response times climb, then timeouts. Fix: use Celery, or call asyncio.create_task() inside an async endpoint for genuinely async I/O.
Mistake 2: Assuming BackgroundTasks persists across restarts
It does not. If your container restarts during a deploy, every queued task is gone. This is fine for a Slack notification, catastrophic for a "charge this customer" task.
Mistake 3: Celery without a result backend
By default, Celery stores results in memory — meaning you cannot query task status, and a worker restart loses in-flight results. Always configure backend= (Redis or DB) for anything where you need to track status.
Mistake 4: Celery for everything "just in case"
I have seen teams add Redis + Celery + Flower + beat for a single Slack notification that fires once an hour. That is a lot of moving parts to babysit for a notification you would not lose sleep over. Use BackgroundTasks for trivial work.
Mistake 5: Not setting acks_late=True on Celery tasks
Default behavior: the worker acknowledges the task as soon as it dequeues. If the worker crashes mid-task, the task is lost. acks_late=True acknowledges only after success — the task re-runs on another worker if the current one dies.
Frequently Asked Questions
Can I use BackgroundTasks with async functions?
Yes. background.add_task() accepts both sync and async callables. For async functions, BackgroundTasks awaits them after the response is sent.
Does BackgroundTasks work with multiple workers (uvicorn workers)?
It runs on the worker that handled the request. If you scale uvicorn to 4 processes, tasks are spread randomly across them — fine for independent work, problematic if you expected ordering or single execution.
Is there a middle ground between BackgroundTasks and Celery?
Yes. arq (async Redis queue) and taskiq are lighter-weight than Celery and play nicely with FastAPI's async model. They give you persistence and retries without Celery's config surface. Worth considering for new projects in 2026.
What about Dramatiq or RQ?
Both are solid alternatives. Dramatiq has better defaults than Celery for retries and rate limiting. RQ (Redis Queue) is the simplest of the three. Pick based on your team familiarity — any of the three solves the same problem Celery does.
Should I use Celery with FastAPI in 2026?
Yes, if you need its features. Celery 5.4+ works well with asyncio (you can call .delay() from async endpoints without blocking). The main friction is operational — you now have a broker and a worker process to manage.
Bottom Line
Both tools are good. The choice is about reliability requirements, not feature checklists.
- BackgroundTasks when the task is fast (<30s), idempotent, and losing one is acceptable.
- Celery when you need persistence, retries, scheduling, or independent worker scaling.
If you are unsure, start with BackgroundTasks. You can move to Celery later — the migration is mostly mechanical (replace background.add_task() with task.delay()). The reverse migration — ripping out Celery once you realize you did not need it — is also easy, but you will wish you had started simpler.