FastAPI Lifespan: The Right Way to Share Resources Across Requests
FastAPI's lifespan context manager replaces deprecated on_event handlers for startup/shutdown. Learn how to share a single pooled httpx.AsyncClient across requests, wire it through dependency injection, and avoid common testing pitfalls.
16 May 2026, 19:23 UTC

The Problem: One Client Per Request Doesn't Scale
You're building a FastAPI service that calls an external API on every request. The naive approach creates a new httpx.AsyncClient inside each route handler. Under load, you burn file descriptors on TCP/TLS handshakes, hit connection limits, and wonder why latency spikes. The fix isn't a bigger server—it's reusing a single pooled client for the entire application lifetime.
FastAPI's lifespan context manager (introduced in Starlette 0.26 and adopted as the primary API in FastAPI 0.95+) gives you a structured place to create that shared client at startup and close it cleanly at shutdown. It replaces the deprecated @app.on_event("startup")/shutdown decorators and integrates directly with the dependency injection system so your route handlers stay testable.
How Lifespan Replaces on_event
The old pattern scattered setup logic across decorator functions:
# Deprecated — still works but emits DeprecationWarning
from fastapi import FastAPI
import httpx
app = FastAPI()
client: httpx.AsyncClient | None = None
@app.on_event("startup")
async def startup():
global client
client = httpx.AsyncClient(timeout=10.0)
@app.on_event("shutdown")
async def shutdown():
await client.aclose()
@app.get("/items")
async def get_items():
resp = await client.get("https://api.example.com/items")
return resp.json()
Global variables make testing awkward and hide the dependency graph. The lifespan approach consolidates everything into a single async context manager passed to the FastAPI constructor:
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
import httpx
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: create shared resource
client = httpx.AsyncClient(timeout=10.0, limits=httpx.Limits(max_connections=100))
# Yield a dict — its keys become attributes on request.state
yield {"http_client": client}
# Shutdown: runs after the last request finishes
await client.aclose()
app = FastAPI(lifespan=lifespan)
Code before yield runs once at startup. Code after runs once at shutdown. The yielded dictionary is merged into request.state for every request, giving you typed access without globals.
Worked Example: Shared HTTP Client with Dependency Injection
Here's a complete, minimal service that fetches data from a downstream API using a single pooled client. The dependency extracts the client from request.state, keeping route handlers pure and swappable in tests.
# main.py
from contextlib import asynccontextmanager
from typing import Annotated
from fastapi import Depends, FastAPI, Request
import httpx
@asynccontextmanager
async def lifespan(app: FastAPI):
# Configure connection pooling for the workload
limits = httpx.Limits(max_connections=50, max_keepalive_connections=10)
client = httpx.AsyncClient(
base_url="https://jsonplaceholder.typicode.com",
timeout=httpx.Timeout(5.0, connect=2.0),
limits=limits,
)
yield {"http_client": client}
await client.aclose()
app = FastAPI(lifespan=lifespan)
# Dependency: pulls the shared client from request.state
async def get_http_client(request: Request) -> httpx.AsyncClient:
return request.state.http_client
HttpClient = Annotated[httpx.AsyncClient, Depends(get_http_client)]
@app.get("/posts/{post_id}")
async def read_post(post_id: int, client: HttpClient):
resp = await client.get(f"/posts/{post_id}")
resp.raise_for_status()
return resp.json()
@app.get("/health")
async def health():
return {"status": "ok"}
Run it with Uvicorn:
# Terminal — requires Python 3.10+, fastapi, uvicorn, httpx installed
uvicorn main:app --reload
Hit http://localhost:8000/posts/1 a few times. The same AsyncClient instance handles every request, reusing TCP connections and TLS sessions. Check the logs on shutdown (Ctrl+C) — you'll see the aclose() call complete cleanly.
Why This Matters for Testing
A common pitfall: tests that instantiate the app directly without entering the lifespan context. Starlette's TestClient and httpx.ASGITransport only run startup/shutdown when used as context managers.
# tests/test_posts.py
import pytest
from httpx import ASGITransport, AsyncClient
from main import app
@pytest.mark.asyncio
async def test_read_post():
# ASGITransport with lifespan="on" enters the context manager
transport = ASGITransport(app=app, lifespan="on")
async with AsyncClient(transport=transport, base_url="http://test") as ac:
resp = await ac.get("/posts/1")
assert resp.status_code == 200
assert resp.json()["id"] == 1
# This test FAILS — no lifespan, request.state.http_client is missing
@pytest.mark.asyncio
async def test_read_post_broken():
transport = ASGITransport(app=app) # lifespan="off" by default
async with AsyncClient(transport=transport, base_url="http://test") as ac:
resp = await ac.get("/posts/1")
# AttributeError: 'State' object has no attribute 'http_client'
Always use lifespan="on" (or the synchronous TestClient as a with block) when your app depends on lifespan-initialized state.
Trade-offs and Limitations
- Version sensitivity: The
yield {dict}→request.statemerge requires Starlette ≥ 0.27. Older versions only supportedyield Nonewith manualapp.state.x = yassignments. Pinfastapi>=0.110,<0.115andstarlette>=0.37,<0.38in your requirements to lock behavior. - Blocking startup work: If you must run synchronous, CPU-bound initialization (e.g., loading a large ML model), don't do it directly in the async lifespan — it blocks the event loop. Offload to a thread pool:
await asyncio.to_thread(load_model)or use a synchronous lifespan variant (@contextmanagerinstead of@asynccontextmanager) for purely sync resources. - No automatic retry on startup failure: If the
yieldline throws, the application never starts. Wrap external dependency connections in try/except and decide whether to fail fast or degrade gracefully. - Single-process assumption: Lifespan runs once per process. In a multi-worker deployment (Gunicorn + Uvicorn workers), each worker gets its own client pool. That's usually what you want, but plan capacity accordingly.
Verify It Works in Your Environment
- Create the
main.pyabove in a fresh virtual environment withfastapi==0.111.0 uvicorn==0.30.0 httpx==0.27.0. - Run
uvicorn main:app --log-level debugand watch for the startup log line (addprint("startup")beforeyieldif needed). - Send a few requests with
curlor a browser. - Stop the server with Ctrl+C and confirm the shutdown log appears (add
print("shutdown")afteraclose()). - Run the test suite with
pytest -v— both tests should pass, and the broken one should fail predictably.
If you see a DeprecationWarning about on_event anywhere, you're on a version where lifespan is the intended API. Migrate incrementally: lifespan and on_event can coexist, but lifespan runs first.
Closing: Make Lifespan Your Default
Any shared resource — database connection pools, Redis clients, HTTP clients, background task schedulers — belongs in lifespan. It gives you a single, visible place to manage the application's lifecycle, keeps route handlers focused on request logic, and makes dependency overrides trivial for testing. Start new FastAPI projects with lifespan from day one; migrate existing on_event handlers when you touch those modules. The pattern pays off the first time you need to swap a real client for a test double without touching a single route handler.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.