FastAPI

FastAPI Middleware (Request Lifecycle, Timing & Request IDs)

Thirdy Gayares
12 min read

šŸŽÆ What You Will Learn

Middleware is code that wraps every request — the perfect place for cross-cutting concerns like timing, request IDs, and logging that don't belong in any single endpoint.

  • The request lifecycle and where middleware sits
  • Your first middleware: a response-timing header
  • Request IDs via request.state
  • Class-based middleware with BaseHTTPMiddleware
  • Middleware ordering (it's an onion, and order matters)
  • Middleware vs dependencies — when to use which

Prerequisites: a basic FastAPI app and familiarity with Depends() (fastapi-dependency-injection), since we'll compare the two.

fastapi-middleware/
ā”œā”€ā”€ requirements.txt
└── app/
    ā”œā”€ā”€ __init__.py
    ā”œā”€ā”€ main.py           # app + middleware registration
    └── middleware.py     # custom middleware

The Mental Model: An Onion Around Every Request

Middleware wraps your endpoints like layers of an onion. Every request passes down through each layer on the way in, hits your route, then passes back up through the same layers on the way out. You get a hook before and after the endpoint runs.

        ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ Middleware A ───────────────┐
        │      ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ Middleware B ──────────┐   │
 in  →  │  →   │   →   [ your endpoint ]   →      │ → │  → out
        │      ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜   │
        ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜

Before endpoint:  A → B        (top to bottom)
After endpoint:   B → A        (bottom back to top)
šŸ’”
Ang importante dito: middleware runs for every request and response, regardless of which route matched. That's exactly why it fits cross-cutting concerns — things every endpoint needs but none should own.

The Problem: Cross-Cutting Code Everywhere (See It First)

Say you want to log how long each request takes. Without middleware, you'd paste the same code into every route:

app/main.py (repeated in every endpoint)
import time
from fastapi import FastAPI

app = FastAPI()


@app.get("/users")
def list_users():
    start = time.perf_counter()          # copy...
    result = {"users": []}
    print(f"took {time.perf_counter() - start:.4f}s")  # ...paste
    return result


@app.get("/orders")
def list_orders():
    start = time.perf_counter()          # again 😩
    result = {"orders": []}
    print(f"took {time.perf_counter() - start:.4f}s")
    return result
āš ļø
Why this hurts: timing has nothing to do with users or orders, yet it clutters every handler. Miss one endpoint and you have a blind spot. Middleware moves this to one place that covers everything.

Your First Middleware: a Timing Header

The simplest form is a function decorated with @app.middleware("http"). It receives the request and a call_next function; you do work before/after calling it.

app/main.py
import time

from fastapi import FastAPI, Request

app = FastAPI(title="FastAPI Middleware", version="0.1.0")


@app.middleware("http")
async def add_timing_header(request: Request, call_next):
    start = time.perf_counter()

    response = await call_next(request)   # run the rest (other middleware + endpoint)

    elapsed = time.perf_counter() - start
    response.headers["X-Process-Time"] = f"{elapsed:.4f}"
    return response


@app.get("/users")
def list_users():
    return {"users": []}

call_next(request) is the "go deeper into the onion" call — it runs the remaining middleware and your endpoint, and returns the response so you can modify it on the way out.

āœ…
One place, every route: now every response carries an X-Process-Time header. Add a hundred endpoints — they're all timed automatically, and no handler knows about it.

Request IDs with request.state

A request ID lets you trace a single request across all your logs. Generate one in middleware, stash it on request.state (readable by endpoints and dependencies), and echo it back in a header.

app/middleware.py
import uuid

from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware


class RequestIDMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        # reuse an incoming id (from a proxy) or generate a new one
        request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
        request.state.request_id = request_id   # available to routes/deps

        response = await call_next(request)
        response.headers["X-Request-ID"] = request_id
        return response
app/main.py
from app.middleware import RequestIDMiddleware

app.add_middleware(RequestIDMiddleware)


@app.get("/whoami")
def whoami(request: Request):
    # anything downstream can read the id off request.state
    return {"request_id": request.state.request_id}
šŸ’”
request.state is a per-request scratchpad. Middleware writes to it; endpoints and dependencies read from it. It's the standard way to pass computed context (request id, current tenant) down the stack.

Class-Based Middleware (BaseHTTPMiddleware)

The @app.middleware decorator is fine for one-offs, but class-based middleware (subclassing BaseHTTPMiddleware) is reusable and configurable — you can pass options in its constructor. Here's a logging middleware that uses the request id from the previous step:

app/middleware.py
import logging
import time

from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware

logger = logging.getLogger("app.access")


class AccessLogMiddleware(BaseHTTPMiddleware):
    def __init__(self, app, *, slow_ms: int = 500) -> None:
        super().__init__(app)
        self.slow_ms = slow_ms   # configurable threshold

    async def dispatch(self, request: Request, call_next):
        start = time.perf_counter()
        response = await call_next(request)
        elapsed_ms = (time.perf_counter() - start) * 1000

        rid = getattr(request.state, "request_id", "-")
        level = logging.WARNING if elapsed_ms > self.slow_ms else logging.INFO
        logger.log(
            level,
            "%s %s -> %s (%.1fms) rid=%s",
            request.method, request.url.path, response.status_code, elapsed_ms, rid,
        )
        return response
app/main.py
from app.middleware import AccessLogMiddleware, RequestIDMiddleware

# configurable: warn on requests slower than 300ms
app.add_middleware(AccessLogMiddleware, slow_ms=300)
app.add_middleware(RequestIDMiddleware)
āœ…
Reusable + configurable: drop AccessLogMiddleware into any project and tune slow_ms per app — no code changes, just a constructor argument.

Middleware Ordering (Important!)

Order is where people get burned. With add_middleware, the last one added is the outermost — it runs first on the way in and last on the way out. It's a stack (LIFO).

app/main.py
app.add_middleware(AccessLogMiddleware, slow_ms=300)  # added 1st -> INNER
app.add_middleware(RequestIDMiddleware)               # added 2nd -> OUTER
Request flow with the code above:

in  →  RequestIDMiddleware  →  AccessLogMiddleware  →  endpoint
out ←  RequestIDMiddleware  ←  AccessLogMiddleware  ←  endpoint

RequestID is OUTER, so request.state.request_id exists
before AccessLog reads it. Reverse the order and the log
shows rid=- because the id isn't set yet.
āš ļø
Gotcha: a middleware that depends on data another middleware sets must be added beforeit (so it ends up inner). Here RequestIDMiddleware must be outer, so it's added last.
šŸ’”
Built-in middleware follows the same rule. CORSMiddleware is usually added last so it's outermost and can handle preflight OPTIONS before anything else runs.

Middleware vs Dependencies

Both run "around" your endpoint, so which do you reach for? The rule: middleware for things that apply to every request and work at the raw request/response level; dependencies for per-route logic that returns a value your handler uses.

Use caseMiddlewareDependency
Applies to every requestāœ…āŒ (per-route)
Needs to return a value to the handlerāŒāœ…
Modify the outgoing response (headers)āœ…āŒ
Auth check for specific routesāš ļø possible but clumsyāœ… ideal
Timing / request-id / access logsāœ…āŒ
Load current user / DB sessionāŒāœ…
šŸ’”
Rule of thumb: if you need the result in your endpoint (a user, a session), use a dependency. If it's a global concern that touches the raw request/response and returns nothing, use middleware.

Try It (curl)

requirements.txt
fastapi==0.116.1
uvicorn[standard]==0.30.6
1Run the app
run.sh
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload
2Inspect the headers middleware added
$ curl -s -D - http://127.0.0.1:8000/whoami -o /dev/null
HTTP/1.1 200 OK
x-process-time: 0.0004
x-request-id: 5f9c8b1a-...          # generated for us

# pass your own request id -> it's reused, not replaced
$ curl -s -D - http://127.0.0.1:8000/whoami -H "X-Request-ID: trace-123" -o /dev/null
x-request-id: trace-123

# server log from AccessLogMiddleware:
# INFO  GET /whoami -> 200 (0.4ms) rid=trace-123
āœ…
You did it: every response is timed and traceable, logs correlate by request id, and not a single endpoint had to know about any of it.

Common Mistakes (and Fixes)

MistakeSymptomFix
Forgetting await call_next(request)Request hangs or errorsAlways return await call_next(request)'s response
Wrong middleware orderReads data another middleware hasn't set yetRemember: last added = outermost (LIFO)
Heavy/blocking work in middlewareEvery single request slows downKeep it light; offload heavy work elsewhere
Using middleware for per-route authAwkward path matching in middlewareUse a dependency on those routes instead
Reading the request body in middlewareEndpoint then sees an empty bodyAvoid consuming the body; if needed, re-inject it carefully
Swallowing exceptions in middlewareErrors vanish, no error responseLet exceptions propagate to exception handlers
āš ļø
Performance: middleware runs on the hot path for every request. Anything slow here (a DB call, an external lookup) taxes your whole API. Keep middleware cheap and non-blocking.

What's Next

āœ…
šŸš€ Recommended next reads:
  • fastapi-logging — turn the access log into structured, searchable JSON logs
  • fastapi-dependency-injection — the per-route counterpart to middleware
  • fastapi-cors — the most common built-in middleware, in depth
  • fastapi-error-handling — where exceptions go after passing through middleware

Recap: middleware wraps every request like an onion — hook before and after via call_next. Use it for cross-cutting concerns (timing, request ids, logging), prefer class-based middleware for reuse, mind the LIFO ordering, and reach for dependencies when you need a value in your handler.

About the Author

TG

Thirdy Gayares

Passionate developer creating custom solutions for everyone. I specialize in building user-friendly tools that solve real-world problems while maintaining the highest standards of security and privacy.