Text size
Dark mode
RecallRunGuides & benchmarks
Learn to build real systemsGuides•Benchmarks•Experiments•Community
Search the site/Free checklist

Guides/FastAPI

FastAPI: Lifespan, state & shared resources

Some things should exist exactly once per process: the database engine and its connection pool, an HTTP client with keep-alive connections, a loaded ML model, a…

In this post

What this post covers
  1. 1. The lifespan context manager
  2. 2. Three lifespan traps, demonstrated
  3. 3. HTTP clients: the most common resource bug
  4. 4. Graceful shutdown
  5. Recap

Some things should exist exactly once per process: the database engine and its connection pool, an HTTP client with keep-alive connections, a loaded ML model, a validated settings object. The lifespan is an async context manager that runs code before the first request and after the last one. Whatever it creates can be handed to every request as state. Getting this right removes a whole family of production bugs: connection storms, leaked sockets, and models reloaded on every request.

🏬

Opening and closing a shop

Before opening the doors you switch on the lights, stock the shelves and open the till. You do not do it for every customer. At closing time you serve the customers already inside, then cash up and lock. Lifespan is opening and closing time. The customers are requests.

1. The lifespan context manager

startup (before yield) engine, http client, model requests share the state (concurrently) shutdown (after yield) close pools, flush telemetry Runs once per worker process: with --workers 4 there are four lifespans and four sets of state.

create once, share with every request, close at shutdown

from contextlib import asynccontextmanager
from typing import Annotated

import httpx
from fastapi import Depends, FastAPI, Request
from fastapi.testclient import TestClient

events = []

def fake_upstream(request: httpx.Request) -> httpx.Response:        # stands in for another service
    return httpx.Response(200, json={"rate": 83.21, "path": request.url.path})

@asynccontextmanager
async def lifespan(app: FastAPI):
    events.append("startup: opening HTTP client (one pool of keep-alive connections)")
    http = httpx.AsyncClient(transport=httpx.MockTransport(fake_upstream), base_url="https://fx.example",
                             timeout=httpx.Timeout(5.0, connect=2.0))         # ALWAYS set timeouts
    model = {"name": "fraud-v3", "loaded": True}                               # e.g. an ML model, loaded once
    yield {"http": http, "model": model}                 # this dict becomes request.state
    await http.aclose()
    events.append("shutdown: HTTP client closed")

app = FastAPI(lifespan=lifespan)

def get_http(request: Request) -> httpx.AsyncClient:
    return request.state.http

@app.get("/convert/{amount}")
async def convert(amount: float, request: Request, http: Annotated[httpx.AsyncClient, Depends(get_http)]):
    rate = (await http.get("/usd-inr")).json()["rate"]
    return {"inr": round(amount * rate, 2), "model": request.state.model["name"]}

with TestClient(app) as client:                          # 'with' runs startup and shutdown
    events.append(f"request: {client.get('/convert/10').json()}")
    events.append(f"request: {client.get('/convert/2.5').json()}")
print("\n".join(events))

Output

startup: opening HTTP client (one pool of keep-alive connections)
request: {'inr': 832.1, 'model': 'fraud-v3'}
request: {'inr': 208.02, 'model': 'fraud-v3'}
shutdown: HTTP client closed
Where to keep itScopeUse for
lifespan yield {...} → request.state.xper process, read in each requestengines, clients, models: the modern, type-friendly choice
app.state.xper processthe same, set imperatively (the app factory in part 07 used it)
request.state.x set by middlewareone requestrequest id, current user, timing (part 12)
module-level globalsper process, created at importavoid for anything with I/O: it runs in tests and tools that merely import the module

2. Three lifespan traps, demonstrated

TestClient without 'with', on_event next to lifespan, and a failing startup

import warnings
from contextlib import asynccontextmanager

from fastapi import FastAPI, Request
from fastapi.testclient import TestClient

log = []

@asynccontextmanager
async def lifespan(app):
    log.append("lifespan startup")
    yield {"db": "engine"}
    log.append("lifespan shutdown")

app = FastAPI(lifespan=lifespan)
with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")

    @app.on_event("startup")                                  # the old API
    async def legacy_startup():
        log.append("on_event startup")
print("1. on_event:", caught[0].category.__name__, "-", str(caught[0].message).split(".")[0].strip())

@app.get("/")
def root(request: Request):
    return {"db": getattr(request.state, "db", "MISSING")}

print("2. without 'with':", TestClient(app).get("/").json())       # lifespan never ran
with TestClient(app) as client:
    print("   with 'with':   ", client.get("/").json())
print("   log:", log, "<- the on_event handler never ran")

@asynccontextmanager
async def broken(app):
    raise ConnectionError("database db-primary:5432 unreachable")
    yield

try:
    with TestClient(FastAPI(lifespan=broken)):
        pass
except ConnectionError as e:
    print("3. startup failure stops the app before any request:", e)

Output

1. on_event: DeprecationWarning - on_event is deprecated, use lifespan event handlers instead
2. without 'with': {'db': 'MISSING'}
   with 'with':    {'db': 'engine'}
   log: ['lifespan startup', 'lifespan shutdown'] <- the on_event handler never ran
3. startup failure stops the app before any request: database db-primary:5432 unreachable

Use with TestClient(app)

Without the context manager the lifespan does not run, and tests pass or fail for the wrong reasons.

Migrate off on_event

It is deprecated, and once a lifespan is set, on_event handlers are silently ignored. That is a nasty surprise during a partial migration.

Fail fast at startup

If a required dependency is down or config is invalid, crash on boot. The orchestrator restarts the process and it never takes traffic in a broken state.

3. HTTP clients: the most common resource bug

Anti-patternWhat happensFix
async with httpx.AsyncClient() as c: inside every requesta new TCP + TLS handshake per call; connection churn; sockets pile up under loadone client per process, created in lifespan, closed at shutdown
requests.get(...) inside async defblocks the event loop: every request stalls (part 11)an async client, or a def endpoint
no timeoutone slow upstream holds every worker hostagehttpx.Timeout(total, connect=…) on every client, always
unbounded connection poolyou overload the upstream during spikeshttpx.Limits(max_connections=…) sized to the upstream's capacity

4. Graceful shutdown

When a deployment replaces a process, the server receives SIGTERM. uvicorn stops accepting new connections, lets in-flight requests finish (bounded by --timeout-graceful-shutdown), then runs your lifespan’s shutdown code. Two consequences:

Shutdown code must be quick

Close pools, flush telemetry buffers (part 21), stop background loops. Orchestrators kill the process after their own grace period (Kubernetes defaults to 30 s).

Unfinished background work is lost

BackgroundTasks still running when the process dies are gone. Anything that must happen belongs in a durable queue (part 13).

Recap

  • lifespan: code before yield runs once at startup, after it at shutdown, per worker process.
  • Yield a dict to expose resources as request.state; wrap access in small dependencies.
  • Tests: with TestClient(app); on_event is deprecated and ignored once lifespan is set; failing startup is a feature.
  • One HTTP client per process, with timeouts and pool limits; keep shutdown quick; move must-not-lose work to a queue.

Get the free RAG production checklist

25 checks to run before real users see your RAG feature: exact-match misses, invented citations, prompt injection through documents, quality that slips after a prompt change. Confirm your email and the PDF comes with the welcome email, plus each new experiment.

One email to confirm. Unsubscribe any time. What's in it · Privacy

Want the production version of this? See ShipRAG: A production RAG + MCP starter kit for FastAPI: hybrid search, validated citations, evals in CI, Docker, 32 tests.

Written by

Alby Tomy

Python/FastAPI Engineer | Data Engineer | AI Engineer building RAG and MCP systems.

X · GitHub · Contact & enquiries