FastAPI Lifespan vs Startup Events: The Mistake That Breaks Async Apps
Introduction
Async bugs in FastAPI rarely fail loudly.
They pass tests.
They work locally.
And then they quietly break under load.
A large number of issues developers blame on FastAPI or SQLAlchemy leaked connections, hanging requests, random MissingGreenlet errors often originate from one place: incorrect application lifecycle management.
This is exactly why FastAPI deprecated @app.on_event and introduced the lifespan protocol.
If you’re building production-grade async systems, understanding this change is not optional.
The “Deprecated” Warning
If you have used FastAPI for a while, you have almost certainly written code like this:
@app.on_event("startup")async def startup_db(): await database.connect()
@app.on_event("shutdown")async def shutdown_db(): await database.disconnect()This approach still works, but it is now legacy.
If you check the current FastAPI documentation, you’ll see a clear warning:
on_event is deprecated.
The modern replacement is the lifespan context manager.
Why This Change Happened
The core problem with on_event was structural.
-
Startup logic lived in one function
-
Shutdown logic lived in another
-
Sharing state between them was awkward
-
Global variables became the default workaround
This separation becomes dangerous in async systems where resource ownership and cleanup must be explicit.
The lifespan protocol solves this by using Python’s async context manager pattern.
You initialize resources before yield, and everything after yield is guaranteed to run during shutdown keeping lifecycle logic in one place.
Why startup Works in Development — and Fails in Production
The most dangerous thing about @app.on_event("startup") is that it usually works at first.
In development:
-
Single worker
-
Low concurrency
-
Short-lived processes
Under these conditions, lifecycle bugs stay hidden.
Problems appear when:
-
Multiple workers are used
-
Containers restart frequently
-
Async DB engines or external clients are introduced
-
Traffic increases
At that point, issues show up as:
-
connection leaks
-
duplicate engine creation
-
inconsistent state across workers
Lifespan forces you to think in process-level ownership, not request-level hacks.
The Refactor: Before vs After
Let’s look at a real-world example:
Connecting to a database and loading an ML model.
The Old Way (on_event)
from fastapi import FastAPIfrom my_db import databasefrom my_ml_lib import load_model
app = FastAPI()ml_models = {}
@app.on_event("startup")async def startup_event(): await database.connect() ml_models["answer_to_everything"] = load_model("model.pkl")
@app.on_event("shutdown")async def shutdown_event(): await database.disconnect() ml_models.clear()Problems:
-
Split lifecycle logic
-
Global state
-
Harder to reason about teardown
-
Fragile in multi-worker setups
The Modern Way (Lifespan)
from contextlib import asynccontextmanagerfrom fastapi import FastAPI
@asynccontextmanagerasync def lifespan(app: FastAPI): # Startup logic await database.connect() app.state.ml_models = { "answer_to_everything": load_model("model.pkl") }
yield
# Shutdown logic await database.disconnect() app.state.ml_models.clear()
app = FastAPI(lifespan=lifespan)Why this is better:
-
Single lifecycle function
-
Explicit resource ownership
-
Guaranteed cleanup
-
Async-safe by design
Accessing Resources Inside Endpoints
FastAPI does not inject yielded values directly into routes.
The recommended pattern is:
-
Initialize resources in lifespan
-
Attach them to
app.state -
Access them via
request.state
Example:
from fastapi import Request
@app.get("/predict")async def predict(request: Request): model = request.state.ml_models["answer_to_everything"] return model.predict()This avoids hidden globals and makes dependencies explicit.
How Lifespan Prevents Common Async SQLAlchemy Errors
Many async SQLAlchemy issues including MissingGreenlet and confusing session behavior are symptoms, not root causes.
They usually happen when:
-
Engines are created per request
-
Sessions are initialized outside a controlled lifecycle
-
Sync and async code paths are mixed
Defining your engine and session factory once inside lifespan eliminates an entire class of async failures.
If you’re using SQLAlchemy 2.0 async with FastAPI, lifecycle correctness matters as much as query correctness.
Poor lifecycle management is one of the most common causes of database connection leaks in FastAPI, which can silently kill production systems.
Common Gotcha: Multiple Lifespans
Some libraries (for example, caching or database helpers) also define their own lifespan handlers.
If you accidentally overwrite them, resources may not initialize or shut down correctly.
For advanced cases, contextlib.AsyncExitStack can be used to orchestrate multiple lifespans but for most applications, a single well-defined lifespan function is sufficient.
Conclusion
Stop writing @app.on_event.
It is legacy code.
The lifespan protocol aligns your FastAPI application with:
-
modern async patterns
-
deterministic startup and teardown
-
the future of the ASGI ecosystem
If you’re building async systems meant to scale, treating lifecycle management as a first-class concern is no longer optional.
Working on something similar?
If you're building backend or AI systems and want a second set of senior eyes, let's talk.