Backend / Web frameworks / FastAPI / 16_background_tasks_vs_celery.md

BackgroundTasks vs Celery — Decision Matrix

Updated 6 interview angles 5 min read source
On this page11
  1. What FastAPI BackgroundTasks does
  2. What Celery does
  3. Decision matrix
  4. When BackgroundTasks is fine
  5. When you need Celery (or equivalent)
  6. Alternatives to Celery
  7. Hybrid: BackgroundTasks → Celery
  8. How BackgroundTasks runs
  9. Reliability in BackgroundTasks: zero
  10. When to keep BackgroundTasks long-term
  11. Interview angle

BackgroundTasks vs Celery — Decision Matrix

FastAPI’s BackgroundTasks and Celery both let you run work “after the response is sent”. They look similar; they solve different problems. Mixing them up is a common production mistake.

What FastAPI BackgroundTasks does

python
from fastapi import BackgroundTasks

@app.post("/users")
async def create_user(user: UserIn, bg: BackgroundTasks):
    new_user = await db.create_user(user)
    bg.add_task(send_welcome_email, new_user.email)
    return new_user

After the response is returned, FastAPI runs send_welcome_email in the same process. That’s it. No queue. No retries. No persistence.

If the process crashes between response-send and task-run, the task is lost. If it raises, FastAPI logs and moves on — no retry, no DLQ.

What Celery does

python
@celery_app.task(bind=True, max_retries=5, autoretry_for=(SMTPError,), retry_backoff=True)
def send_welcome_email(self, email):
    smtp.send(email)

@app.post("/users")
async def create_user(user: UserIn):
    new_user = await db.create_user(user)
    send_welcome_email.delay(new_user.email)
    return new_user

delay() enqueues a message to the broker (Redis / RabbitMQ / SQS). A separate worker process picks it up, runs it, retries on failure, dead-letters on exhaustion. Crash-resilient.

Decision matrix

Concern BackgroundTasks Celery
Out-of-band work that doesn’t affect the response yes yes
Process crash safety task lost message in broker
Retries no yes
Schedule for later (cron) no Celery Beat
Run for minutes / hours holds the worker process yes
Distributed across many workers same process yes
Monitoring / inspectability no Flower, Prometheus exporter
Dependencies (broker + worker + result backend) none Redis or RabbitMQ + worker fleet
Cost zero broker infrastructure + workers
Latency to start µs (same process) ms (broker round-trip)
Use for trivial fire-and-forget anything that matters

When BackgroundTasks is fine

  • Sending a single email after signup. Fire-and-forget — if it fails, retry isn’t critical (or you have a retry layer in your email provider).
  • Logging / metrics emission. Best-effort observability.
  • Invalidating a cache key. Stale-tolerant.
  • Pre-warming something. Best-effort.

Rule of thumb: if losing the task is acceptable, BackgroundTasks works.

When you need Celery (or equivalent)

  • Payment processing, billing, financial transactions. Cannot lose.
  • Anything with retries. “Send this even if the SMTP server is flaky.”
  • Long-running work. Image transcoding, batch jobs, ML inference.
  • Scheduled tasks. Daily reports, hourly cleanups.
  • Workflows. Multi-step processes with branching, compensation (consider Temporal).
  • Work that scales independently of the API. Worker fleet sized to backlog.

Alternatives to Celery

Tool Notes
RQ simpler than Celery, Redis-only; fewer features but less overhead
arq async-native, Redis-only; nice fit with FastAPI
dramatiq another simpler Celery alternative; cleaner API
AWS SQS + Lambda serverless workers; no broker to operate
Temporal workflow orchestration (long-running, sagas, retries built in); see Temporal
Kafka + consumers event-streaming pattern; for high-throughput, ordered processing

For an async FastAPI app, arq is a nice fit — same asyncio ecosystem, Redis-based, simple API.

Hybrid: BackgroundTasks → Celery

A pattern: use BackgroundTasks to enqueue to Celery, so the API response doesn’t wait on the broker round-trip even:

python
async def enqueue_email(email):
    # very fast but technically blocking
    send_welcome_email.delay(email)

@app.post("/users")
async def create_user(user: UserIn, bg: BackgroundTasks):
    new_user = await db.create_user(user)
    bg.add_task(enqueue_email, new_user.email)
    return new_user

Probably overkill for a single .delay() call (it’s already fast), but useful if the enqueue path is heavier.

How BackgroundTasks runs

FastAPI / Starlette runs background tasks in the same event loop after the response is returned. For sync def tasks, runs them in the threadpool (same as for sync routes).

A long-running BackgroundTask will hold up the response cleanup but does not block other concurrent requests — they run on the loop concurrently. But the process must stay alive until the task finishes; in K8s, terminationGracePeriodSeconds must cover it.

Reliability in BackgroundTasks: zero

python
@app.post("/charge")
async def charge(req: ChargeIn, bg: BackgroundTasks):
    await stripe.charge(...)         # commits the money
    bg.add_task(notify_finance, req)  # if this never runs, finance never knows
    return {"ok": True}

Process crashes between the response and notify_finance running → finance is silently out of sync. This is not acceptable for anything load-bearing.

The right shape: write the side effect to your DB in the same transaction as the business change, then a separate process picks it up reliably. Transactional outbox pattern — see Event-Driven Architecture and Sagas or the new outbox file.

When to keep BackgroundTasks long-term

Even in a Celery-heavy app, BackgroundTasks remains useful for:

  • Response observability — emit a metric after the response.
  • Logging that doesn’t fit in middleware — domain-specific audit log line.
  • In-process cache priming — warm something the next request will want.

The thing they all share: losing them is fine.

Interview angle 6

  • “BackgroundTasks vs Celery — when each?” — BackgroundTasks: in-process, no persistence, no retries, no broker. Use for fire-and-forget where loss is acceptable. Celery: durable queue, retries, distributed workers, scheduled tasks. Use for anything that must not be lost.
  • “You have a 30-second video transcoding step after upload. BackgroundTasks?” — no. Too long for an in-process task (ties the worker, no crash recovery, no retry, no isolation). Push to Celery / Fargate task / Lambda + S3 trigger.
  • “What happens if the process crashes mid-BackgroundTask?” — task is lost silently. No retry, no DLQ. For anything where loss matters, use a real queue.
  • “Are there async-native alternatives to Celery for FastAPI?” — arq (Redis, async-native, simpler API), dramatiq, RQ (sync). For workflows (multi-step, long-running, sagas), Temporal. For AWS-native, SQS + Lambda.
  • “How would you implement a ‘send email after signup’ that must not be lost?” — write the email job to your DB inside the user-creation transaction (outbox pattern), or push to Celery / SQS. Don’t rely on BackgroundTasks.
  • “What’s the latency cost of Celery vs BackgroundTasks?” — BackgroundTasks runs in-process, sub-millisecond. Celery has broker round-trip (~ms on Redis local, more on cross-region brokers). For most user-facing flows the broker round-trip is invisible.