Async Patterns & Background Tasks
Food Tracker (FastAPI)
Chapter 10 · Async Patterns & Background Tasks
This course has already used async two different ways — a single external call in Chapter 3, a concurrent fan-out in Chapter 9. This chapter steps back to make the decision rule explicit, then adds one genuinely new tool: doing real work after a response has already been sent.
When async def Actually Helps
| Route | What it waits on | async def helps? |
|---|---|---|
| Chapter 3 — lookup_barcode | A network call to Open Food Facts | Yes — genuine idle waiting time, real benefit |
| Chapter 6 — get_alerts | A local SQLite query | Barely — the query finishes almost immediately |
| Chapter 9 — suggest_recipes | Several network calls, run concurrently | Yes — the whole point of asyncio.gather() |
The rule, stated plainly: async def earns its keep when a route genuinely waits on something slow and external — a network call, most often. A route that only touches a fast local resource gains little from being async, and Chapter 6 was written as a plain def specifically for that reason.
Doing Work After the Response Is Sent
Every mutation so far has made the client wait for absolutely everything to finish before responding. BackgroundTasks lets a route respond immediately and still do a little extra work afterward — here, logging every "marked used" event to its own table:
background_tasks.add_task(...) schedules log_usage to run after the response has already been sent — the client gets its confirmation immediately, without waiting for the audit-log write to complete.
log_usage opens its own SessionLocal() rather than reusing mark_used's own db parameter — the same pattern Chapter 9's lookup_ingredient used, but for a genuinely different underlying reason. Chapter 9's issue was concurrency: several tasks running at the same time couldn't safely share one session. Here, the issue is lifecycle: by the time log_usage actually runs, the request is already fully complete, and get_db's own finally: db.close() has already closed mark_used's session. A background task reusing that closed session would fail outright — it isn't a race condition to avoid, it's a session that's simply already gone.
log_usage runs in the same process, after the response — genuinely useful for quick, best-effort extra work, but with a real limit worth stating plainly: if the server process crashes or restarts in the narrow window between sending the response and the task actually executing, that task is simply lost, with no retry and no record that it was ever supposed to run. A real job queue (Celery, RQ, or similar) persists tasks somewhere durable and can retry them after a crash — BackgroundTasks offers none of that. It's the right tool for a nice-to-have audit log; it would be the wrong tool for something that genuinely must happen, like sending a payment confirmation.
A Note on Connection Pooling
check_same_thread=False matters specifically because of this chapter's own material: FastAPI runs synchronous functions like log_usage in a background threadpool, a different thread than the one that originally handled the request. SQLite's default behavior disallows using a connection from a different thread than the one that created it, purely as a safety guard — this flag deliberately relaxes that, since SessionLocal()'s own connection handling (not manual thread-sharing) is what's actually managing safe access here.
Where This Course Is Headed
Deployment next — environment config, running with Uvicorn/Gunicorn, and a real production checklist.
Hands-On Exercises
Using this chapter's own table, explain the general rule for when async def genuinely helps a route, and why Chapter 6's get_alerts was written as a plain def despite this course being "async-native."
📄 View solutionExplain why log_usage needs its own SessionLocal() rather than reusing mark_used's own db parameter, and how this reason genuinely differs from why lookup_ingredient needed its own session in Chapter 9.
📄 View solutionExplain what "BackgroundTasks is not a durable job queue" actually means in practice, describing a concrete scenario where a scheduled background task could be silently lost.
📄 View solutionChapter 10 Quick Reference
- The rule: async def helps when a route waits on something slow and external — not every route benefits equally
- BackgroundTasks: background_tasks.add_task(...) runs work after the response is already sent
- log_usage's own session: a fresh SessionLocal(), because the request's own session is already closed by the time this runs — a lifecycle issue, distinct from Chapter 9's own concurrency issue
- Real limit: BackgroundTasks is in-process and best-effort — a crash before it runs loses it silently, with no retry
- check_same_thread=False: needed because sync background tasks run in a different thread than the request that scheduled them
- SQLite has no real connection pool — that concept matters for a networked database like PostgreSQL, not a local file
- Next chapter: Deployment