BackgroundTasks — Work After the Response
IntermediateBackgroundTasks runs functions after the response is sent — perfect for emails, logs, and webhooks the user shouldn't wait for. Know its limits: in-process, no retries, dies with the worker — that's where Celery/ARQ enter.
Overview
The user registers; you must send a welcome email that takes two seconds. Making them stare at a spinner while SMTP negotiates is pointless — inject BackgroundTasks, add_task the email, return immediately: FastAPI runs the queued functions after the response leaves. It is the right tool for fire-and-forget side effects measured in milliseconds-to-seconds: notification sends, audit writes, webhook pings, cache warms. But it is deliberately primitive — tasks run in the same process (a deploy or crash kills queued work), there are no retries, no persistence, no visibility, and heavy tasks steal capacity from request serving. The moment work is critical (must not be lost), slow (minutes), or needs retries/scheduling, it graduates to a real task queue — Celery or ARQ backed by Redis — where jobs persist, workers scale separately, and failures re-run. The interview question is precisely this boundary.
add_task — Respond Now, Work After
Inject BackgroundTasks, queue functions with their arguments, return. Tasks run in order after the response; sync tasks go to the thread pool, async tasks to the loop — endpoint latency includes neither. Dependencies with yield still wrap them.
import logging, time
from fastapi import BackgroundTasks, FastAPI
app = FastAPI()
log = logging.getLogger("tasks")
def send_welcome_email(email: str, name: str):
time.sleep(2) # SMTP being SMTP (sync is fine here —
log.info("welcome mail sent to %s", email) # it runs in the thread pool)
def audit(event: str, **fields):
log.info("AUDIT %s %s", event, fields)
@app.post("/register", status_code=201)
def register(payload: dict, background: BackgroundTasks):
user = {"id": 101, "email": payload["email"], "name": payload["name"]}
# ... hash password, insert user (the REQUEST work) ...
background.add_task(send_welcome_email, user["email"], user["name"])
background.add_task(audit, "user.registered", user_id=user["id"])
return {"id": user["id"]} # ← returns NOW; email sends after
# Timeline:
# 0ms request in → user created
# 15ms 201 response SENT — user's spinner is gone
# 15ms+ send_welcome_email runs (2s), then audit — user never waited
#
# Works inside dependencies too (e.g., a usage-metering dependency that
# add_tasks a counter bump for every call). Same BackgroundTasks object
# is shared between dependencies and the endpoint.The Limits — and When You Need a Real Queue
BackgroundTasks is a convenience, not an insurance policy. Enumerate what it lacks, then the graduation criteria and what the Celery/ARQ world adds.
# What BackgroundTasks does NOT give you:
# persistence — deploy/crash between response and task = task GONE, silently
# retries — SMTP down for 30s? that email is never sent
# visibility — no dashboard of pending/failed work
# isolation — a 5-min PDF render hogs a worker thread your REQUESTS need
# scheduling — no "retry in 5 min", no "run nightly at 2 AM"
# scale-out — tasks run where the request landed, period
# Graduation test — move to a real queue when ANY is true:
# □ losing the task costs money/trust (payment webhook, OTP, invoice)
# □ it takes > a few seconds (report, video transcode, bulk import)
# □ it needs retries with backoff
# □ it needs its own scaling (10 API pods, 40 GPU workers)
# The queue world (ARQ shown — async-native, Redis-backed; Celery = the
# heavyweight classic with beat scheduling, chains, rate limits):
#
# ── worker.py ──
# async def generate_marksheet_pdf(ctx, student_id: int):
# ...render 3 minutes of PDF...
# class WorkerSettings:
# functions = [generate_marksheet_pdf]
# redis_settings = RedisSettings(host="redis")
#
# ── endpoint: enqueue, hand back a handle ──
# @app.post("/marksheets/{student_id}", status_code=202) # 202 = Accepted
# async def request_marksheet(student_id: int, request: Request):
# job = await request.app.state.arq.enqueue_job(
# "generate_marksheet_pdf", student_id)
# return {"job_id": job.job_id, "status_url": f"/jobs/{job.job_id}"}
#
# Job lives in Redis → survives deploys, retries on failure, separate
# worker fleet, pollable status. 202 + status URL is the REST pattern
# for "accepted, working on it".Key Points to Remember
- 1add_task queues work that runs after the response — user never waits for side effects
- 2Sync tasks → thread pool, async tasks → loop; endpoint latency excludes both
- 3No persistence, retries, or visibility — a crash silently loses queued tasks
- 4Critical, slow, or retry-needing work → Celery/ARQ with 202 + job-status URL
Interview Questions
Sign in to ask AriaWelcome email vs payment webhook processing — BackgroundTasks or Celery for each, and why?
What happens to pending BackgroundTasks when you deploy? Design around it.
Design the API for a 3-minute report generation — status codes, URLs, client flow.
Ask Aria about BackgroundTasks — Work After the Response
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.