ASGI and the Request Lifecycle
ASGI is the interface that lets an asynchronous Python web server talk to an application such as FastAPI. In this lesson, the useful outcome is concrete: when a request reaches a FastAPI service, you should be able to trace which component sees it, what data shape it receives, where validation happens, how middleware wraps the endpoint, when startup and shutdown code runs, and why a single blocking call can slow unrelated requests.
This belongs early in FastAPI Foundations because routes, dependencies, validation, testing, and deployment all sit on top of the same lifecycle. FastAPI feels high level, but every request still moves through the ASGI message protocol implemented by Starlette underneath FastAPI.
Purpose and Outcome
ASGI stands for Asynchronous Server Gateway Interface. It is not a web server and it is not a framework. It is a callable contract. A server such as Uvicorn accepts sockets, parses HTTP, and calls your application with a connection scope plus two awaitable functions named receive and send. The application reads inbound events from receive and emits outbound events through send.
FastAPI hides most of that machinery during everyday endpoint work, but the machinery explains several practical behaviors: middleware order, request body streaming, lifespan startup, dependency cleanup, background tasks, exception handling, and concurrency limits. If you understand the lifecycle, you can place work in the right layer instead of guessing.
ASGI Internals
An ASGI application is called once per connection or protocol interaction. For an HTTP request, the server builds a dictionary called scope. It contains values such as type, method, path, headers, query_string, and client address information. The request body is not necessarily placed in the scope because it may arrive in chunks. Body chunks arrive as ASGI events from receive, commonly http.request messages. The response is sent as http.response.start followed by one or more http.response.body messages.
FastAPI delegates the ASGI protocol handling to Starlette. Starlette turns the raw scope and event functions into a Request object, runs middleware, matches a route, solves dependencies, calls the endpoint, converts the return value into a Response, and sends the response messages. FastAPI adds API-focused features around that pipeline: type-driven parameter extraction, Pydantic validation, OpenAPI generation, response model serialization, and dependency injection.
The lifecycle has two major tracks. The request track handles http and websocket scopes. The lifespan track handles application startup and shutdown. Lifespan is where you open shared resources such as database pools, HTTP clients, model handles, or caches, then close them cleanly when the server stops. Request handlers should borrow those resources; they should not recreate expensive clients for every call.
Lifecycle Anatomy
- The ASGI server accepts a client connection and parses the incoming HTTP request.
- The server creates the HTTP scope and calls the FastAPI application.
- Outer middleware receives the request first. Each middleware may inspect, reject, modify, or pass the request inward by awaiting the next application.
- Starlette routing selects the path operation by method and path pattern.
- FastAPI extracts path, query, header, cookie, and body parameters according to the endpoint signature.
- Pydantic validates and coerces data. Bad input usually becomes a
422response before endpoint code runs. - Dependencies are resolved. Dependencies may themselves depend on other dependencies and may use
yieldfor cleanup after the response path completes. - The endpoint coroutine or function runs. Async endpoints run on the event loop; ordinary sync endpoints are run in a thread pool by Starlette.
- FastAPI serializes the return value, applies response models where configured, and builds a response object.
- Response middleware unwinds outward. The first middleware entered is the last middleware to finish the response.
- The ASGI app sends response start and body events back to the server.
API and Configuration Surface
The main API shape is small. FastAPI() creates an ASGI app. Decorators such as @app.get() register route handlers. @app.middleware("http") wraps HTTP traffic. Depends() declares values that FastAPI must resolve before calling the endpoint. lifespan=... supplies startup and shutdown behavior with an async context manager.
The most important design detail is that the endpoint function signature is part of the request parser. A parameter named item_id: int in the path is not just documentation; it tells FastAPI to parse that path segment as an integer and reject invalid values. A Request parameter asks FastAPI to pass the Starlette request object so you can inspect lower-level information when necessary.
Example 1: Minimal ASGI App Through FastAPI
from fastapi import FastAPI
app = FastAPI()
@app.get("/ping")
async def ping():
return {"message": "pong"}
This program is a complete ASGI application because app is callable by an ASGI server. With Uvicorn serving this module, GET /ping returns status 200 and JSON {"message":"pong"}. No request body is read, no dependencies are solved, and FastAPI only needs route matching plus response serialization. It is the smallest useful lifecycle: server to app, route to endpoint, return value to response.
Example 2: Middleware, Path Parsing, and Query Parsing
from fastapi import FastAPI, Request
app = FastAPI()
@app.middleware("http")
async def add_timing_header(request: Request, call_next):
response = await call_next(request)
response.headers["X-Example"] = "middleware-ran"
return response
@app.get("/items/{item_id}")
async def read_item(item_id: int, q: str | None = None):
return {"item_id": item_id, "q": q}
This example adds two lifecycle features. The middleware runs before routing on the inbound path and after the endpoint on the outbound path. The route also shows parameter extraction: item_id comes from the path and q comes from the query string. A request to /items/42?q=blue returns {"item_id":42,"q":"blue"} and includes header X-Example: middleware-ran. A request to /items/not-an-int does not call read_item; validation fails and FastAPI returns 422.
Example 3: Lifespan State and Dependencies
from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends
class Catalog:
def __init__(self) -> None:
self.ready = True
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.catalog = Catalog()
yield
app.state.catalog.ready = False
app = FastAPI(lifespan=lifespan)
def get_catalog() -> Catalog:
return app.state.catalog
@app.get("/catalog/health")
async def catalog_health(catalog: Catalog = Depends(get_catalog)):
return {"ready": catalog.ready}
The lifespan context manager creates one Catalog when the application starts and marks it closed during shutdown. The dependency returns that shared object to the endpoint. In a real service this pattern is appropriate for database pools or reusable HTTP clients. GET /catalog/health returns {"ready":true} while the app is running. The trade-off is that shared state must be safe for concurrent access. Store clients and pools here, not mutable per-request data.
Example 4: Blocking Work and Concurrency
import time
import anyio
from fastapi import FastAPI
app = FastAPI()
@app.get("/bad-delay")
async def bad_delay():
time.sleep(0.2)
return {"done": True}
@app.get("/good-delay")
async def good_delay():
await anyio.sleep(0.2)
return {"done": True}
The two endpoints look similar, but they behave differently under concurrency. bad_delay blocks the event loop with time.sleep. During that sleep, other async work scheduled on the same loop cannot make progress. good_delay yields control with await anyio.sleep, allowing the loop to serve other requests while waiting. Deterministically, both return {"done":true}; operationally, the blocking version causes inflated latency for unrelated requests when traffic overlaps.
Testing the Lifecycle
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
response = client.get("/items/42?q=blue")
print(response.status_code)
print(response.json())
print(response.headers["X-Example"])
If main.py contains the second example, this script prints 200, then {'item_id': 42, 'q': 'blue'}, then middleware-ran. The test client drives the ASGI app without opening a real network port, so it is useful for verifying routing, validation, middleware side effects, dependency behavior, and lifespan behavior in a controlled process.
Design Choices and Trade-offs
Use async def when the endpoint awaits nonblocking I/O, such as an async database driver or async HTTP client. Use ordinary def for CPU-light blocking libraries that do not offer an async API; Starlette can run sync endpoints in a worker thread. Do not mark a handler async and then call blocking database, filesystem, or network APIs inside it. That combines the readability of async with the runtime behavior of a traffic jam.
Middleware is best for cross-cutting behavior that truly applies around many routes: request IDs, coarse timing, CORS, compression, authentication envelopes, or response headers. Dependencies are usually better for route-specific concerns such as loading the current user, opening a transaction, checking permissions, or selecting a tenant. Lifespan is best for process-wide resources. Endpoint code is best for the business action itself.
Streaming bodies and responses are another ASGI trade-off. ASGI supports chunked request and response events, but reading await request.body() buffers the whole body in memory. That is convenient for small JSON requests and risky for large uploads. For large payloads, prefer streaming interfaces or upload handling designed for bounded memory use.
Failure Modes and Troubleshooting
Symptom: requests that should be independent become slow together. Cause: blocking work is running inside async def. Diagnose: check slow endpoints for time.sleep, synchronous HTTP clients, synchronous database drivers, large CPU loops, or file reads. Use load tests with overlapping requests rather than single requests. Correct: switch to async libraries, make the handler sync so it runs in the thread pool, or move CPU-heavy work to a worker process.
Symptom: custom response headers are missing on errors. Cause: middleware order or exception handling returns before the header-setting middleware unwinds. Diagnose: build a small failing route and inspect headers for normal and error responses. Check whether middleware is installed as function middleware, class middleware, or server middleware. Correct: place header logic in middleware that wraps the relevant exception path, or use exception handlers that add the same header intentionally.
Symptom: endpoints fail with missing shared clients during tests. Cause: lifespan did not run, or the test imported a dependency before application startup initialized state. Diagnose: assert the state key exists during a request and use TestClient as a context manager when testing lifespan. Correct: initialize process-wide resources in lifespan and structure tests so startup and shutdown execute.
Symptom: clients receive 422 before endpoint logging appears. Cause: FastAPI validation rejected path, query, header, cookie, or body input before the handler was called. Diagnose: inspect the response body detail and compare it to the endpoint signature. Correct: fix the client payload or adjust the type annotation, default, alias, or Pydantic model to match the intended contract.
Security, Performance, and Reliability Implications
The lifecycle is also a control surface. Validate input before expensive work. Avoid reading unbounded bodies into memory. Keep authentication and authorization decisions close enough to routing that protected handlers cannot be reached accidentally. Do not store request-specific data in app.state; concurrent requests share the same application object. For reliability, give outbound calls timeouts and release resources in dependency cleanup or lifespan shutdown. For performance, measure p95 and p99 latency under concurrent traffic because event-loop blocking often hides during single-request manual testing.
Hands-on Lab: Trace a Request
Prerequisites: Python, FastAPI, Uvicorn, HTTPX or curl, and permission to run a local development server. Create a file named main.py with the middleware example above. Start it with uvicorn main:app --reload. Request http://127.0.0.1:8000/items/42?q=blue. Verify status 200, body {"item_id":42,"q":"blue"}, and header X-Example: middleware-ran. Then request /items/nope and verify 422; this proves validation happened before endpoint execution. Add a temporary print("endpoint ran") inside the route and confirm it appears for 42 but not for nope. Cleanup by stopping Uvicorn and removing the temporary print. No persistent rollback is needed unless you added external resources.
Assessment Exercises
- Draw the inbound and outbound order for two HTTP middleware functions around one endpoint. Which middleware sees the request first, and which sees the response last?
- An async endpoint calls a synchronous payment SDK and p99 latency rises for unrelated routes. Explain the mechanism and give two corrections.
- A dependency opens a transaction with
yield. Where should commit or rollback logic live, and why is endpoint return not the end of cleanup? - Design a test that proves invalid path parameters are rejected before endpoint code runs.
- Choose whether a database pool belongs in middleware, a dependency, lifespan, or endpoint code. Defend the choice and name one concurrency risk.
Summary
FastAPI applications are ASGI applications. The ASGI server provides scope, receive, and send; Starlette runs the protocol and routing core; FastAPI layers validation, dependency injection, serialization, and documentation on top. The request lifecycle explains where to put middleware, dependencies, lifespan resources, and endpoint logic. The main operational lesson is simple but important: async code only helps concurrency when waiting work yields control instead of blocking the event loop.
