Under the hood: ASGI and Starlette

FastAPI · Expert · 18 min

Debugging middleware order, streaming, 500s without CORS headers or performance means knowing what actually runs per request.

ASGI is the interface between servers and Python async apps. An ASGI app is just async def app(scope, receive, send). Uvicorn parses the HTTP bytes and calls it once per connection or request:

  • scope is a dict describing the connection: type ("http", "websocket" or "lifespan"), method, path, query_string, and headers as a list of (bytes, bytes) pairs.
  • await receive() returns events such as {"type": "http.request", "body": b"...", "more_body": False}; bodies may arrive in chunks.
  • await send(...) emits http.response.start (status and headers) and then one or more http.response.body messages, which is how streaming works.

FastAPI subclasses Starlette's application. On the first request it builds the middleware stack, outermost first: ServerErrorMiddleware (turns uncaught exceptions into 500s and runs your Exception handler), your middleware in reverse order of adding, ExceptionMiddleware (HTTPException and your other handlers), FastAPI's exit-stack middleware, then the router. The router tries routes in order; the matching APIRoute reads the body, solves dependencies, calls your function and serializes the result.

That layout explains real surprises:

  • A 500 is produced outside your middleware, so it carries no CORS headers and the browser reports a CORS error instead of the crash.
  • @app.middleware("http") is built on BaseHTTPMiddleware, which wraps each request in extra tasks and streams; it's convenient but adds overhead. Hot-path middleware is better written as pure ASGI, as in the example, wrapping send to edit headers.
  • Anything ASGI can be mounted: app.mount("/static", StaticFiles(directory="static")), another FastAPI app, or a WSGI app via an adapter.

Starlette's Request is a thin, lazy view over scope and receive.

Example

import time
from fastapi import FastAPI
from fastapi.testclient import TestClient
from starlette.types import ASGIApp, Message, Receive, Scope, Send

class TimingMiddleware:                      # pure ASGI: no BaseHTTPMiddleware
    def __init__(self, app: ASGIApp) -> None:
        self.app = app

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        if scope["type"] != "http":           # pass websocket/lifespan through
            return await self.app(scope, receive, send)
        start = time.perf_counter()

        async def send_wrapper(message: Message) -> None:
            if message["type"] == "http.response.start":
                ms = f"{(time.perf_counter() - start) * 1000:.1f}"
                message["headers"] = [*message["headers"], (b"server-timing", f"app;dur={ms}".encode())]
            await send(message)

        await self.app(scope, receive, send_wrapper)

app = FastAPI()
app.add_middleware(TimingMiddleware)

@app.get("/")
def root():
    return {"ok": True}

print(TestClient(app).get("/").headers["server-timing"])   # app;dur=0.9 (varies)

Remember

  • An ASGI app is async (scope, receive, send); responses are sent as messages
  • Stack: ServerErrorMiddleware > your middleware > ExceptionMiddleware > router
  • 500 responses bypass user middleware, so they lack CORS and custom headers
  • Pure ASGI middleware avoids BaseHTTPMiddleware's overhead

Watch out for

  • Reading the request body in BaseHTTPMiddleware and expecting streaming endpoints to behave normally.

Check yourself

  1. A browser shows a CORS error, but the server log shows an unhandled exception. Why does the error look like CORS?
  2. In ASGI, how does a server send a streamed response?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: www.starlette.io/middleware/