Your FastAPI Middleware May Never See the Whole Response

| | 9 min read

A FastAPI middleware can return a response before a streaming response has finished sending its body. That is the shorthand behind this headline: the middleware can inspect the response object, yet still not have seen every byte the client will receive. To understand why, think of middleware as a wrapper around an ASGI application, not as a callback that owns the entire request and response.

That boundary matters for logging, security, custom headers, and streaming. Here, “middleware” means application middleware in FastAPI, not middleware in the web server or reverse proxy. The familiar FastAPI decorator handles HTTP requests. ASGI middleware can receive other scope types, such as WebSocket and lifespan, only if it handles them.

What FastAPI Middleware Actually Wraps

FastAPI is built on Starlette and follows ASGI, the Asynchronous Server Gateway Interface. An ASGI server calls an application with a scope plus asynchronous receive and send functions. For HTTP, the scope describes a request; the application consumes request events and sends response events. Middleware is another ASGI application placed around the inner application. It can inspect or wrap those inputs, call the inner app, and observe or modify what flows back out. The ASGI specification describes this middleware model, and FastAPI documents compatibility with ASGI middleware.

The familiar FastAPI function middleware translates that lower-level arrangement into a convenient HTTP interface: it receives a Request and call_next, awaits the rest of the application, and gets a response object back. That makes common jobs approachable, but it is still a wrapper with ordering and response-lifecycle boundaries.

import time

from fastapi import FastAPI, Request

app = FastAPI()

@app.middleware("http")
async def add_process_time(request: Request, call_next):
    started = time.perf_counter()
    response = await call_next(request)
    response.headers["X-Process-Time"] = f"{time.perf_counter() - started:.6f}"
    return response

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

Save this as main.py, install FastAPI and Uvicorn with pip install fastapi uvicorn, then run uvicorn main:app --reload. A request to /health returns JSON plus the timing header. This timing measures through response creation, not necessarily the time until a streaming body finishes reaching the client. In ASGI, response start and body chunks are separate events; that distinction matters for large or long-lived streams. See the HTTP and WebSocket ASGI event specification.

Why FastAPI Middleware May Not See the Error You Expect

Starlette builds a stack with ServerErrorMiddleware on the outside, installed middleware inside it, then ExceptionMiddleware, routing, and endpoints. Handled exceptions, such as an endpoint raising HTTPException, become ordinary responses within that inner stack. Unhandled errors instead bubble outward as exceptions so the server-error layer can produce a 500 response. Starlette says error-logging middleware should re-raise the exception all the way to the server. Its exception documentation explains this stack and the difference between handled exceptions and errors.

Code after response = await call_next(request) is not a universal “finally” hook. If the downstream call raises, execution skips the next line unless you catch the exception or use a finally block. If you catch the error and turn it into a response, you may also change how Starlette’s error handlers see it. For observability, log unexpected exceptions and re-raise them:

import logging

from fastapi import FastAPI, Request

logger = logging.getLogger(__name__)
app = FastAPI()

@app.middleware("http")
async def log_unhandled_errors(request: Request, call_next):
    try:
        return await call_next(request)
    except Exception:
        logger.exception(
            "Unhandled request error: %s %s",
            request.method,
            request.url.path,
        )
        raise

This logs unexpected exceptions that cross the middleware and preserves normal error handling. It does not replace structured application logging, server access logs, tracing, or error reporting. Avoid logging credentials, cookies, full authorization headers, or unfiltered request bodies.

FastAPI Middleware Order Changes What You Observe

Each middleware wraps the next one. FastAPI’s documented rule is that the last middleware added is outermost: it runs first on the request path and last on the response path. If you add middleware A and then B, the order is B request, A request, route, A response, B response. FastAPI’s middleware guide shows this stack ordering.

Placement controls what a middleware observes. An outer response wrapper may see headers added by an inner wrapper; an inner one cannot observe changes that an outer middleware makes later. This matters for CORS, compression, exception handling, and custom headers. Check the framework’s ordering rules instead of inferring behavior from the visual order of declarations.

FastAPI Middleware Does Not Automatically See a Whole Stream

Adding a response header after call_next is simple because the middleware receives a response object. Changing the body is more involved. A response may stream chunks, and consuming or buffering those chunks can change memory use, latency, backpressure, or whether streaming works at all. Reading a request body in middleware has similar consequences because the endpoint also needs access to that stream.

If your job is to add a header, do not read and rebuild the body. If you need to inspect or transform ASGI events, pure ASGI middleware gives direct access to send and receive, but then you own the protocol details. Check streaming responses, disconnects, and error paths rather than assuming every response is a small JSON document.

When FastAPI Middleware Should Use Pure ASGI

For ordinary timing, simple logging, or a response header, the decorator is usually the clearest choice. Choose pure ASGI middleware when you need to wrap protocol events directly, support more than HTTP, or avoid a documented limitation of Starlette’s BaseHTTPMiddleware.

Starlette documents that BaseHTTPMiddleware can prevent changes to contextvars.ContextVar values set downstream from propagating back upstream. That can surprise tracing or request-context code. Pure ASGI middleware avoids that specific limitation and is how Starlette implements its own middleware. Read Starlette’s documentation for the limitation and pure ASGI pattern.

This example adds a request ID response header. It passes non-HTTP scopes through untouched, creates a fresh ID per request, wraps only the response sender, and keeps no request data on the shared middleware instance:

from uuid import uuid4

class RequestIdMiddleware:
    def __init__(self, app):
        self.app = app

    async def __call__(self, scope, receive, send):
        if scope["type"] != "http":
            await self.app(scope, receive, send)
            return

        request_id = uuid4().hex

        async def send_with_request_id(message):
            if message["type"] == "http.response.start":
                headers = list(message.get("headers", []))
                headers.append((b"x-request-id", request_id.encode("ascii")))
                message = {**message, "headers": headers}
            await send(message)

        await self.app(scope, receive, send_with_request_id)

Register the class through FastAPI’s add_middleware method so it sits in Starlette’s managed stack:

from fastapi import FastAPI

app = FastAPI()
app.add_middleware(RequestIdMiddleware)

The example generates an ID at the application boundary. If a trusted proxy already assigns request IDs, validate and propagate that value according to your deployment’s trust boundary instead of blindly trusting a client-supplied header. ASGI recommends copying a scope before modifying it; this example does not modify the scope. See the ASGI guidance on scope mutation.

FastAPI Middleware Is Not the Authorization Layer

Authentication and authorization often need route parameters, validated request data, and the identity of the resource being accessed. FastAPI dependencies are frequently a better fit because you can declare them at the application, router, or path-operation level and use dependency injection. Generic middleware can reject traffic before routing, but it does not automatically know whether a particular user may access a particular record. Keep resource-level authorization close to the operation and data it protects.

Rate limiting also needs shared state when an application has multiple worker processes or replicas. An in-memory counter in middleware stays local to one process and resets when that process restarts. A robust limit usually needs a shared store and a deliberate policy for keys, windows, bursts, and failure behavior. Middleware can be a convenient enforcement point, but it does not provide the distributed algorithm or durable state by itself.

Use FastAPI Middleware Built for the Job

FastAPI exposes Starlette middleware for common concerns. CORSMiddleware handles browser cross-origin policy and preflight requests. Its wildcard origin setting does not permit credentialed browser communication; when cookies or authorization are involved, specify allowed origins and the methods and headers you need. CORS is a browser access policy, not authentication. FastAPI documents origins, credentials, and preflight behavior.

TrustedHostMiddleware validates the Host header, HTTPSRedirectMiddleware redirects requests to HTTPS, and GZipMiddleware compresses eligible responses. These utilities can save you from reimplementing established behavior, but proxy configuration still matters. Forwarded headers such as X-Forwarded-For and X-Forwarded-Proto should be trusted only when supplied by known proxies. Uvicorn does not trust them by default. FastAPI explains how to configure trusted proxy addresses.

A Practical FastAPI Middleware Decision Rule

Before adding middleware, name the exact boundary you need:

  • Use a FastAPI dependency for a decision tied to a route, user, or resource.
  • Use function middleware for simple HTTP request and response work.
  • Use pure ASGI middleware when you need event-level control, protocol coverage, or predictable context propagation.
  • Use the framework or server’s built-in middleware for standard concerns such as CORS, compression, and trusted hosts.
  • Use an external shared store or edge component when behavior must coordinate across workers or services.

Then verify the behavior that matters: execution order, handled and unhandled exceptions, streaming, request-body access, context variables, proxy trust, and concurrency across workers. Middleware is powerful because it wraps the application boundary. That same position is why it cannot solve every concern by itself.

For a related look at reducing repetitive FastAPI endpoint code without hiding architecture, see FastCRUD for FastAPI: Less Repetitive CRUD, Not Less Architecture.

The post Your FastAPI Middleware May Never See the Whole Response appeared first on Alpesh Kumar.

Subscribe to Our Newsletter

We don’t spam! Read our privacy policy for more info.