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

RouteWhat it waits onasync def helps?
Chapter 3 — lookup_barcodeA network call to Open Food FactsYes — genuine idle waiting time, real benefit
Chapter 6 — get_alertsA local SQLite queryBarely — the query finishes almost immediately
Chapter 9 — suggest_recipesSeveral network calls, run concurrentlyYes — 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:

# models.py (appended) class UsageLog(Base): __tablename__ = "usage_log" id = Column(Integer, primary_key=True) item_id = Column(Integer, nullable=False) item_name = Column(String, nullable=False) logged_at = Column(DateTime, server_default=func.now())
# routers/items.py from fastapi import BackgroundTasks from database import SessionLocal def log_usage(item_id: int, item_name: str): db = SessionLocal() # the request's own session is already closed by now try: db.add(models.UsageLog(item_id=item_id, item_name=item_name)) db.commit() finally: db.close() @router.patch("/{item_id}/use", response_model=schemas.ItemResponse) def mark_used(item_id: int, background_tasks: BackgroundTasks, db: Session = Depends(get_db)): item = ( db.query(models.Item) .filter(models.Item.id == item_id, models.Item.status == "active") .first() ) if item is None: raise HTTPException(status_code=404, detail="Item not found, or already used") item.status = "used" item.used_at = datetime.utcnow() item.expiry_date = None db.commit() db.refresh(item) background_tasks.add_task(log_usage, item.id, item.name) return item

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.

The same dedicated-session discipline as Chapter 9, for a different reason
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.
BackgroundTasks is not a durable job queue
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

# database.py engine = create_engine( "sqlite:///./foodtracker.db", connect_args={"check_same_thread": False}, )

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.

SQLite doesn't really have a "pool" the way a networked database does
A real connection pool exists to reuse a limited number of expensive network connections to a remote database server — genuinely valuable for something like PostgreSQL. SQLite is a local file, not a network service; there's no remote connection to establish or reuse in the same sense. A high-traffic, genuinely concurrent production deployment would be a real reason to move to PostgreSQL and a proper connection pool — an honest, deliberately out-of-scope upgrade path for this app's own realistic single-household scale, not something this course builds.

Where This Course Is Headed

Deployment next — environment config, running with Uvicorn/Gunicorn, and a real production checklist.

Hands-On Exercises

Exercise 1

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 solution
Exercise 2

Explain 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 solution
Exercise 3

Explain 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 solution

Chapter 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