The request lifecycle: middleware onion, dependency resolution, lifespan, and where errors surface
uvicorn accepts, middleware wraps inward (last added runs first), the router matches, dependencies resolve with per-request caching, yield teardown after send. Pools live in lifespan, BackgroundTasks occupy the worker, 422/500 surface at fixed layers.
The new orders service passed staging with flying colors at half a request per second. Day one of production traffic — 50 rps — it survived eleven minutes. Postgres started refusing connections with FATAL: sorry, too many clients already; pg_stat_activity showed hundreds of idle connections all owned by the orders pods; every endpoint that touched the database returned 500. The cause was four innocent lines: a dependency that did pool = await asyncpg.create_pool(...) and yielded it. Dependencies run per request — so every request paid a ~300 ms pool handshake and parked ten fresh connections, and at 50 rps the database’s max_connections = 100 ceiling was gone in roughly two seconds of sustained traffic; only connection idle-timeouts had kept staging alive. The fix was not a bigger Postgres. It was moving pool creation to where process-scoped resources belong — lifespan, once per worker — and letting the dependency do the one job it is scoped for: borrowing a connection for the duration of a request, and giving it back.
After this lesson you will be able to place any resource — a connection pool, an HTTP client, a per-request session — in exactly the right lifecycle scope, and you will know which layer answers which kind of error before you reach for the debugger.
The path in: accept, scope, onion, route
uvicorn accepts the TCP connection, parses HTTP into an ASGI scope, and calls the outermost application. From there the request walks an onion: each middleware is an ASGI app wrapping the next one, running code on the way in, calling inward, then running code on the way out as the response passes back through. Ordering is therefore architecture, and Starlette’s registration order trips people: add_middleware wraps the current stack, so the last middleware added is the outermost — it sees the request first and the response last:
app.add_middleware(AuthMiddleware) # added first → inner
app.add_middleware(RequestIdMiddleware) # added second → OUTER, runs first
# request: RequestId → Auth → router → handler
# response: handler → Auth → RequestIdThe order encodes policy. Request-id and logging go outermost so every request — including ones rejected later — is observable. Whether auth or rate-limiting comes next is a real decision: a per-user rate limit needs auth to have run already (it keys on identity); a per-IP limit goes outside auth so unauthenticated floods burn no token-verification CPU. Past the onion, the router matches the path and method, and matching hands off to the most underrated stage of the pipeline: dependency resolution.
Dependencies: a per-request graph with scoped teardown
FastAPI resolves the handler’s dependency graph fresh for every request, with two properties that carry production weight. First, caching: the same dependency appearing several places in one request’s graph executes once, and everyone shares the result (use_cache=True is the default) — get_current_user called by the handler, a permission check, and an audit dependency is one token verification, not three. Second, yield dependencies are scoped resources: code before yield runs on the way in, the yielded value is injected, and the code after yield is teardown — guaranteed to run, including when the handler raises:
async def get_conn(request: Request):
async with request.app.state.pool.acquire() as conn: # borrow from lifespan pool
async with conn.transaction():
yield conn # handler runs here
# commit/rollback + release happen on unwindTeardown timing has teeth: since FastAPI 0.106, yield-dependency teardown runs after the response is sent but before background tasks execute. A background task that reaches for the request’s database session finds it closed — sessions for background work must be created by the task itself, not borrowed from a dependency that has already torn down.
app.add_middleware(AuthMiddleware) runs first in the source file, then app.add_middleware(RateLimitMiddleware). Which middleware sees an incoming request first?
Lifespan owns process resources
The lifespan scope from lesson 1 is where things that live as long as the process get built: connection pools, HTTP clients, model weights, caches. They are created once per worker at startup, shared via app.state, and closed cleanly at shutdown:
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.pool = await asyncpg.create_pool(DSN, min_size=5, max_size=10)
yield # the app serves requests here
await app.state.pool.close()
app = FastAPI(lifespan=lifespan)The Hook incident is the canonical inversion of this rule — a pool per request instead of per process. The arithmetic is worth keeping: pools cost a connection handshake (~hundreds of ms for TLS + auth) and hold sockets open; Postgres defaults to max_connections = 100. One pool of 10 per worker × 4 workers × 2 pods = 80 connections, deliberate and stable. One pool per request at 50 rps = ceiling breached within seconds, and the failure arrives as everyone else’s 500s.
After the response: background tasks, streaming, and where errors surface
Before you reach for BackgroundTasks, ask yourself: does this job need to survive a deploy? Does it need to be retried on failure? If either answer is yes, it does not belong here. BackgroundTasks runs functions after the response is sent — on the same worker. That is the feature and the trap: the client is unblocked, but the worker is not. A 60-second report generator attached as a background task occupies the worker after every response; at modest traffic the loop (or the threadpool, for sync tasks) is permanently busy doing non-request work, and tasks die with the process on deploy — they are in-memory, unretried, unobserved. The honest rule: background tasks are for sub-second fire-and-forget (an email enqueue, a cache poke); anything heavier goes to a real queue with retries and its own workers. For large payloads, StreamingResponse with an async generator sends chunks as they are produced — a 2 GB CSV export streams in constant memory instead of materializing in RAM.
Errors surface at fixed layers, and knowing which layer answers is most of debugging. Validation failure happens during dependency/argument resolution — the handler never runs, the client gets a 422 with field locations. HTTPException raised in a handler or dependency is caught by the framework’s exception middleware and rendered as its status code, and yield-dependency teardown still runs. An unhandled exception propagates outward through the onion — every middleware on the path sees it (your logging middleware can record it) — until the outermost server-error layer answers 500. The response is already lost at that point; what your middleware ordering decides is who gets to observe the corpse.
A handler attaches a 60-second report-generation function via BackgroundTasks and returns in 80 ms. What actually happens?
- 01Trace a request end to end: middleware order semantics, dependency caching, yield-dependency teardown timing, and what runs after the response.
- 02Name the lifecycle failure modes: pool-per-request, BackgroundTasks misuse, and the three places errors surface.
The lifecycle is an onion with a schedule. uvicorn turns bytes into an ASGI scope and hands it to the outermost middleware — which is the last one registered, because add_middleware wraps the current stack; that inversion is why auth-versus-rate-limit ordering is a code-review item and not a style choice. The router matches, and FastAPI resolves the dependency graph once per request: shared nodes execute once thanks to the default cache, and yield dependencies bracket the handler with setup and guaranteed teardown — teardown that fires after the response is sent and before background tasks, which is exactly why a background task must never borrow the request’s database session. Process-scoped resources do not belong in that graph at all: pools and clients are built once in lifespan, live on app.state, and are closed at shutdown — the pool-per-request dependency is incident number one, breaching max_connections = 100 within seconds at 50 rps while staging at half a request per second never noticed. After the response, the worker is still yours to lose: BackgroundTasks run on it, so sub-second pokes are fine and 60-second reports drown the service; durable work belongs on a queue, and 2 GB exports stream chunk by chunk in constant memory. Errors keep fixed addresses — 422 from validation before the handler, status codes from HTTPException, a 500 from the outermost layer for anything unhandled — and your middleware order decides who gets to witness them on the way out. Now when you see a 500 cascade after a dependency change, you know to trace the onion from the outside in — and when you see max_connections exhausted on a low-traffic service, you know to look for a pool created inside a dependency.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.