backend / web frameworks / fastapi / 16_background_tasks_vs_celery.md

BackgroundTasks vs Celery — Decision Matrix

6 interview angles 5 min read source

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

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

@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 10_message_queues/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:

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

@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

@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 13_architecture_design/15_event_driven_saga.md 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

  • “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.