Writing Pure ASGI Middleware¶
Middleware runs on every request, so its overhead is multiplied by your entire traffic. Starlette's BaseHTTPMiddleware — the dispatch(request, call_next) style FastAPI tutorials use — is convenient and expensive: it wraps the downstream app in a task and streams the response body through memory object streams. A pure ASGI middleware is a callable that receives scope, receive and send and wraps them directly. Measured with Starlette 1.7 on Uvicorn (uvloop, httptools), a trivial route served 35,669 requests per second with no middleware; adding one BaseHTTPMiddleware that set a timing header dropped it to 8,806; five of them to about 1,200. Five pure ASGI middlewares doing the same work still served 30,696, and one served 33,867. This guide writes pure ASGI middleware for the common jobs — headers, timing, request ids, short-circuit responses — and covers the details that make it correct.
Prerequisites¶
- Python 3.11+, any ASGI framework; examples use Starlette/FastAPI.
- ASGI basics, from ASGI Servers & Frameworks.
- Context variables for request state, from propagating request IDs with contextvars.
1. Write the minimal pass-through¶
A pure ASGI middleware is a class that holds the next app and implements the ASGI call. Pass through anything it does not handle — lifespan and websocket scopes in particular:
class PassThrough:
def __init__(self, app) -> None:
self.app = app
async def __call__(self, scope, receive, send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send) # lifespan, websocket: untouched
return
await self.app(scope, receive, send)
app = Starlette(routes=routes, middleware=[Middleware(PassThrough)])
# or wrap directly: app = PassThrough(app)
There is no Request object, no call_next and no extra task: the middleware is one more function call on the stack. Everything it does, it does by inspecting scope and by wrapping receive (what comes in) and send (what goes out).
Verify: wrap an app in the pass-through and run its test suite; behaviour, including websockets and lifespan, is unchanged.
2. Modify responses by wrapping send¶
To add headers or observe the status code, wrap send and intercept the http.response.start message:
import time
class TimingHeader:
def __init__(self, app) -> None:
self.app = app
async def __call__(self, scope, receive, send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
start = time.perf_counter()
async def send_wrapper(message):
if message["type"] == "http.response.start":
headers = list(message.get("headers", []))
headers.append((b"x-response-time", f"{time.perf_counter() - start:.6f}".encode()))
message = {**message, "headers": headers}
await send(message)
await self.app(scope, receive, send_wrapper)
Headers in ASGI are a list of (bytes, bytes) pairs, lowercased by convention. Copy the message rather than mutating it, since the downstream app may reuse it. Because the body passes through untouched, this works for streaming responses with no buffering — exactly where BaseHTTPMiddleware historically caused problems. The value measured is time to the start of the response; to time the whole body, record the moment the last http.response.body message with more_body false passes through.
Verify: a streaming response arrives incrementally, with the header present, through this middleware.
3. Short-circuit without calling the app¶
Authentication, rate limiting and maintenance modes answer some requests without calling the app. Send the response messages directly and return:
class ApiKeyGate:
def __init__(self, app, keys: set[bytes]) -> None:
self.app, self.keys = app, keys
async def __call__(self, scope, receive, send) -> None:
if scope["type"] == "http":
key = dict(scope["headers"]).get(b"x-api-key")
if key not in self.keys:
body = b'{"error":"unauthorized"}'
await send({"type": "http.response.start", "status": 401,
"headers": [(b"content-type", b"application/json"),
(b"content-length", str(len(body)).encode())]})
await send({"type": "http.response.body", "body": body})
return
await self.app(scope, receive, send)
dict(scope["headers"]) keeps the last value for a repeated header, which is fine for a single API key header but not for headers that may legitimately repeat. Starlette's Response objects are themselves ASGI apps, so await PlainTextResponse("no", 401)(scope, receive, send) is a readable alternative. The same short-circuit shape is used in rate limiting incoming requests in ASGI apps.
Verify: a request without a key gets 401 and the application handler is never invoked.
4. Set request context the right way¶
Request ids, tenant ids and similar per-request values belong in context variables set in middleware, so handlers and loggers can read them. Set and reset around the downstream call:
import contextvars
import uuid
request_id: contextvars.ContextVar[str] = contextvars.ContextVar("request_id", default="-")
class RequestId:
def __init__(self, app) -> None:
self.app = app
async def __call__(self, scope, receive, send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
rid = dict(scope["headers"]).get(b"x-request-id", b"").decode() or uuid.uuid4().hex
token = request_id.set(rid)
async def send_wrapper(message):
if message["type"] == "http.response.start":
message = {**message, "headers": [*message.get("headers", []),
(b"x-request-id", rid.encode())]}
await send(message)
try:
await self.app(scope, receive, send_wrapper)
finally:
request_id.reset(token)
Because a pure middleware calls the app directly in the same task, the context variable set here is visible in the handler. With BaseHTTPMiddleware, the downstream app runs in a separate task, so context set in dispatch relied on copying behaviour that has changed across Starlette versions — one more reason to prefer the pure form. Validate an incoming request id's length and characters before trusting it into logs.
Verify: a log line emitted inside a handler carries the same request id the response header returns.
5. Order and test the stack¶
Middleware order is outside-in: the first in the list sees the request first and the response last. Put cheap rejections (rate limits, auth) outermost so rejected requests do the least work, and timing outermost if it should include everything:
app = Starlette(
routes=routes,
middleware=[
Middleware(TimingHeader), # outermost: sees the full duration
Middleware(RequestId),
Middleware(ApiKeyGate, keys=KEYS),
Middleware(GZipMiddleware), # Starlette's own, pure ASGI
],
)
Test middleware directly with an ASGI test client, including streaming and disconnects. The ASGI transport in httpx runs the app in-process with no server:
async def test_request_id_round_trip():
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://t") as c:
r = await c.get("/", headers={"x-request-id": "abc123", "x-api-key": "k"})
assert r.headers["x-request-id"] == "abc123"
Request-body handling — limiting size, reading it for signatures — wraps receive instead of send, as shown in limiting request body size in ASGI apps.
Verify: each middleware has a test, and the stack's order is asserted by a test that checks rejected requests never reach inner middleware.
Verification¶
Pure ASGI middleware is correct when:
- Non-HTTP scopes pass through unchanged — lifespan and websockets still work.
- Streaming responses stream through every middleware, without buffering.
- Context variables set in middleware are visible in handlers, and reset afterwards.
- Throughput with the full stack is close to the app without middleware, measured.
Diagnostic Hook: measure your app's requests per second with and without its middleware stack under the same load, and record the ratio per release. A ratio that drops sharply after a change means someone added a BaseHTTPMiddleware or a middleware doing per-request I/O; the five-layer measurement above shows how fast that compounds.
Pitfalls & edge cases¶
- Handling only
httpand dropping other scopes. Lifespan events never reach the app and startup never runs. - Mutating the message dict in place. Copy it before changing headers.
- Reading
receivein middleware without replaying it. The app then sees an empty body. - Stacking
BaseHTTPMiddleware. Each layer adds a task and stream machinery; five layers measured at about 1,200 req/s.
Frequently Asked Questions¶
Why is Starlette's BaseHTTPMiddleware slow?
It runs the downstream app in a separate task and passes the response through memory streams. In testing with Starlette 1.7, one such middleware took a trivial route from 35,669 to 8,806 requests per second, while a pure ASGI middleware doing the same work kept 33,867.
How do I write ASGI middleware?
Write a class that stores the next app and implements async call(scope, receive, send). Pass non-HTTP scopes through, wrap send to change responses, wrap receive to inspect the request body, or send a response directly to short-circuit.
How do I add a header to every response in ASGI middleware?
Wrap send and, when the message type is http.response.start, append a (name, value) byte pair to a copy of its headers list before forwarding it.
Do context variables set in middleware reach the handler?
In pure ASGI middleware, yes, because the app runs in the same task. Set the variable before calling the app and reset it with the token afterwards.
Related¶
- ASGI Servers & Frameworks — up to the topic overview.
- Detecting client disconnects in ASGI handlers — another job done by wrapping receive.
- Network I/O & Protocol Handling — the section overview.