Managing ASGI Lifespan Startup and Shutdown¶
Every async service needs things that exist for the life of the process and not longer: a database pool, an HTTP client with a warm connection pool, a broker consumer, a metrics exporter. Creating them at module import is the instinctive choice and the wrong one — there is no event loop yet, so anything that opens a socket fails or, worse, binds to a loop that is later replaced. The ASGI lifespan protocol exists for exactly this: one async context manager whose setup runs before the first request and whose teardown runs after the last one. This guide builds it against Starlette 1.6, FastAPI 0.141 and uvicorn 0.53, verifying the ordering guarantees rather than assuming them.
Prerequisites¶
- Python 3.11+ with
starletteorfastapianduvicorn. - Async context managers from building managers with asynccontextmanager.
- Shutdown sequencing from Graceful Shutdown & Signals.
1. Write the lifespan as one context manager¶
Everything before the yield is startup, everything after is shutdown, and a try/finally guarantees the pairing:
import contextlib
@contextlib.asynccontextmanager
async def lifespan(app):
app.state.pool = await asyncpg.create_pool(DSN) # needs a running loop
consumer = asyncio.create_task(consume_events(app.state.pool))
try:
yield {"pool": app.state.pool} # the app serves requests here
finally:
consumer.cancel()
with contextlib.suppress(asyncio.CancelledError):
await consumer # wait for it to actually stop
await app.state.pool.close()
app = Starlette(routes=routes, lifespan=lifespan)
The finally matters because a failure during serving — or a cancellation from the server — must still close the pool. And awaiting the cancelled task, not merely cancelling it, is what makes the shutdown ordered: without that await, the process can exit while the consumer is still unwinding.
The older @app.on_event("startup") and @app.on_event("shutdown") decorators are deprecated in both Starlette and FastAPI. They also encourage the shape that causes bugs — two functions communicating through module globals, with nothing structurally pairing a setup with its teardown.
Verify: the startup block runs before the first request is served and the shutdown block after the last.
2. Yield state instead of using globals¶
Whatever the lifespan yields becomes available on every request, and it is the same object rather than a copy:
async def handler(request):
rows = await request.state.pool.fetch("SELECT 1") # the pool the lifespan created
Verified directly: a handler comparing request.state.pool is request.app.state.pool returned True. Both routes to the object work; the state dict is the one that survives being mounted inside a larger application and keeps handlers testable, because a test can supply its own state without patching module globals.
In FastAPI the same objects are usually reached through dependencies:
async def get_pool(request: Request) -> asyncpg.Pool:
return request.state.pool
@app.get("/users")
async def users(pool: asyncpg.Pool = Depends(get_pool)):
...
That indirection pays for itself in tests, where overriding the dependency replaces the pool without touching the app.
Verify: a handler and the lifespan see the identical object, not a rebuilt one.
3. Own every background task you start¶
A consumer, a poller, a cache refresher or a metrics pusher started at startup must be cancelled at shutdown, or the process will not exit cleanly. The lifespan is where that ownership belongs:
tasks = [asyncio.create_task(t()) for t in (consume_events, refresh_cache, push_metrics)]
try:
yield
finally:
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True) # wait for all of them
Verified ordering from a run with a heartbeat task: startup, then heartbeat cancelled, then shutdown — the task stopped before the resources it used were closed, which is the order that avoids "connection closed" errors during shutdown.
A TaskGroup is the more structured alternative, though it inverts the shape: the group must be held open across the yield, so the lifespan becomes async with asyncio.TaskGroup() as tg: ... yield ... with the group's exit doing the cancelling. Either works; what matters is that no task is created without something that will stop it.
Verify: after shutdown, asyncio.all_tasks() contains nothing the app created.
4. Let a failed startup stop the process¶
If the database is unreachable, a service that starts anyway will serve errors and pass its liveness probe. Raising from the lifespan prevents that:
@contextlib.asynccontextmanager
async def lifespan(app):
raise RuntimeError("database unreachable")
yield
uvicorn's response is unambiguous: the server exits with SystemExit(3) immediately, measured at 0.00 s. The orchestrator then restarts the container, backs off, and eventually reports CrashLoopBackOff — which is a much better signal than a pod that is up and failing every request.
The judgement call is which dependencies deserve this treatment. A database a service cannot function without: yes, fail fast. An optional cache or a metrics backend: no — log the failure, start degraded, and let the readiness probe reflect the state. Bound the startup work with a timeout either way, so an unreachable dependency fails in seconds rather than hanging the deploy.
Verify: breaking the database connection string makes the process exit non-zero rather than serve.
5. Know where shutdown sits in the sequence¶
The measured ordering, with a one-second handler and a SIGTERM-equivalent sent 200 ms in:
in-flight request -> 200 after 0.81s
log: ['startup', 'slow handler finished', 'shutdown begins', 'shutdown done']
The server stops accepting new connections, existing requests finish normally, and only then does the lifespan's shutdown block run. So resources used by handlers are still open while those handlers complete — which is exactly what you want, and why closing the pool in a signal handler instead of the lifespan breaks in-flight requests.
Two settings shape this. timeout_graceful_shutdown caps how long uvicorn waits for in-flight requests; without it, one long request holds the deploy. And the readiness probe should report not-ready before the shutdown starts, so the load balancer stops routing while the server is still accepting — the drain delay described in health and readiness probes.
Verify: a request in flight when SIGTERM arrives completes with a 200, and the shutdown block runs after it.
Verification¶
A lifespan is correct when:
- All loop-dependent setup is in it, not at module import.
- Teardown is paired with setup through one
try/finally. - Background tasks are cancelled and awaited before resources close.
- Required dependencies fail startup and the process exits non-zero.
- State reaches handlers through
request.stateor dependencies, not globals. - In-flight requests complete before the shutdown block runs.
Pitfalls & edge cases¶
- Creating clients at import time. No loop exists yet;
httpx.AsyncClient()created there binds to the wrong loop or fails. - Cancelling without awaiting. The process can exit while tasks are still unwinding, skipping their cleanup.
- Unbounded startup. A dependency that hangs makes the deploy hang; wrap startup in
asyncio.timeout. --reloadin development. The lifespan runs per reload; slow startup makes every code change painful.- Multiple workers. The lifespan runs once per worker process, so anything that must happen once per deploy — migrations, for example — does not belong there.
- Mounted sub-applications. A mounted app's lifespan is not run automatically by every version; check, or hoist the setup to the parent.
Frequently Asked Questions¶
How do I run startup and shutdown code in FastAPI or Starlette?
Write an async context manager decorated with @contextlib.asynccontextmanager, put setup before the yield and teardown after it, and pass it as lifespan=... to the application. The older @app.on_event("startup") and ("shutdown") decorators are deprecated.
Where should I create a database connection pool in an ASGI app?
In the lifespan startup block, because creating a pool needs a running event loop. Yield it as state so handlers reach it through request.state, and close it in the teardown after in-flight requests have finished.
What happens if ASGI lifespan startup raises an exception?
The server refuses to start. With uvicorn, the process exits with SystemExit(3) immediately, so an orchestrator restarts it and eventually reports a crash loop. That is the right outcome for a dependency the service cannot work without; optional dependencies should log and start degraded instead.
Does the lifespan shutdown run before or after in-flight requests finish?
After. Measured with a one-second handler and a shutdown triggered 200 ms in, the request returned 200 at 0.81 s and only then did the shutdown block run. Resources used by handlers are therefore still open while those handlers complete.
Does the lifespan run once per process or once per worker?
Once per worker process. With uvicorn --workers 4 the startup block runs four times, so each worker gets its own pool and its own background tasks. Work that must happen exactly once per deploy, such as migrations, belongs in a separate job.
Related¶
- ASGI Servers & Frameworks — up to the topic overview.
- Sizing uvicorn workers — why the lifespan runs more than once.
- Running background tasks in FastAPI — tasks that outlive a request.
- Network I/O & Protocol Handling — the section overview.