Guide 01
Async code vs background jobs
Audience: FastAPI engineers choosing where work should run. Reading time: ~8 min Keywords: FastAPI async, BackgroundTasks, durable jobs, Celery, Taskiq
TL;DR
| Need | Put the work here |
|---|---|
| Data for this response | async def (or sync route if intentionally blocking) |
| Tiny best-effort after response | FastAPI BackgroundTasks |
| Must retry, scale, or survive deploys | Durable job queue (RabbitMQ) or event bus (Kafka) |
async only means “don’t block the event loop while waiting on I/O.” It does not mean durable, retriable, or independently scalable.
Contents
- Three layers people mix up
- Decision tree
- What belongs in the request
- BackgroundTasks rules
- Durable jobs
- Pseudocode patterns
- Performance pitfalls
- Checklist mapping
---
1. Three layers people mix up
| Layer | Runs where | Survives crash/deploy? | Scales how? | Use when |
|---|---|---|---|---|
async def in a request | Same request, event loop | No | With API replicas | Fast I/O needed for the response |
FastAPI BackgroundTasks | After response, same process | No | Stuck on that API pod | Tiny, non-critical cleanup |
| Job queue + worker | Separate process | Yes (durable broker) | Worker replicas / queues | Must retry, scale, or outlive deploys |
| Domain event + consumer | Separate process | Yes | Consumer groups / lag | Many services react to a fact |
2. Decision tree
Must the HTTP response body include this work's result?
YES → do it in the request path (async I/O), or
enqueue + return 202 and let the client poll job status
NO → Would users/business care if this work is lost on deploy?
NO → BackgroundTasks is acceptable (keep it tiny)
YES → Durable queue (command on RabbitMQ) or event (Kafka)Examples
| Work | Placement |
|---|---|
| Load user + permissions for GET | Request path |
| Send “welcome” email after signup | Job queue |
| Write analytics pixel after 200 | BackgroundTasks OK |
| Generate 50MB export | Job queue (jobs.heavy) |
| Charge card + ledger entry | Request for charge API or command job; never BackgroundTasks |
| Fan-out “order placed” to billing + email | Domain event (Kafka) after commit |
3. What belongs in the request
Keep request handlers short and predictable:
- Validate input (Pydantic)
- Authorize
- One transaction (or a clear saga start)
- Return a response
If work is slow but the user must wait, still avoid holding the connection for minutes: prefer 202 + job_id and a status endpoint.
Async vs sync routes
# Prefer async for concurrent I/O (DB, HTTP clients)
@app.get("/users/{user_id}")
async def get_user(user_id: int, db: Db = Depends(get_db)):
return await db.fetch_user(user_id)
# Sync is OK if the whole stack is sync and you understand the thread pool
@app.get("/report-sync")
def report_sync():
return heavy_cpu_report() # better: offload to workerRule: never call blocking I/O inside async def without asyncio.to_thread / a proper executor. Prefer async drivers (asyncpg, httpx.AsyncClient).
4. BackgroundTasks rules
Allowed:
- Delete a temp file
- Best-effort metrics that you already accepted losing
- Non-critical cache warm
Forbidden for production-critical work:
- Emails users expect
- Payment webhooks
- PDF invoices
- Anything with SLAs or legal weight
# PSEUDOCODE : OK: best-effort
@app.post("/events")
async def track(event: EventIn, bg: BackgroundTasks):
bg.add_task(log_event_best_effort, event)
return {"ok": True}5. Durable jobs
When work must survive:
- Insert job row (
pending) in your DB - Publish command to RabbitMQ (or outbox → broker)
- Return 202 +
job_id - Worker runs, updates status, retries with bounds
- Client polls
GET /jobs/{id}or receives a webhook
See 03 Job lifecycle and 04 Outbox.
6. Pseudocode patterns
Request-only async
# PSEUDOCODE
@app.get("/users/{user_id}")
async def get_user(user_id: int, db: Db = Depends(get_db)):
user = await db.fetch_user(user_id)
if not user:
raise HTTPException(404)
return userEnqueue durable work (preferred)
# PSEUDOCODE
@app.post("/exports", status_code=202)
async def start_export(body: ExportIn, db: Db, bus: JobBus, user=Depends(auth)):
async with db.transaction():
job = await db.insert_job(
type="export_csv",
status="pending",
entity_id=str(user.id),
idempotency_key=body.idempotency_key,
payload_ref=body.filters_ref,
)
await bus.publish(
queue="jobs.heavy",
message={"job_id": str(job.id), "type": "export_csv"},
)
return {"job_id": job.id, "status": "pending"}Wrong: critical work only on BackgroundTasks
# PSEUDOCODE : DO NOT DO THIS for mail users expect
@app.post("/signup")
async def signup(body: SignupIn, bg: BackgroundTasks):
user = await create_user(body)
bg.add_task(send_welcome_email, user.id) # lost on deploy/crash
return user7. Performance pitfalls
| Pitfall | Symptom | Fix |
|---|---|---|
Sync HTTP inside async def | Latency spikes, worker stalls | Async client or thread offload |
| Huge work in request | Timeouts, user abandons | 202 + job |
| Unbounded BackgroundTasks | Memory growth under load | Cap work; move to queue |
| CPU in async worker without process pool | Event loop lag | Separate heavy queue / prefork |
8. Checklist mapping
- Development: no blocking in async routes; BackgroundTasks limited
- Jobs: durable broker for critical work; 202 + job_id
- Anti-patterns: critical email only on BackgroundTasks