Introduction

When a request fails in production, the first question is usually: how long did that request take? The fix is to write middleware with BaseHTTPMiddleware: it attaches a unique X-Request-ID to every request and measures the processing time, returned in the X-Process-Time header — applied to every endpoint without touching each endpoint’s code.

The problem

The most “naive” approach is to measure time inside each endpoint and stuff the result into the response body:

import time
from fastapi import FastAPI
app = FastAPI()
@app.get("/users")
def list_users():
start = time.perf_counter()
users = [{"id": 1, "name": "An"}]
return {"users": users, "process_time": f"{time.perf_counter() - start:.3f}s"}

This approach has three drawbacks. First, you must repeat the two timing lines in every endpoint, and any endpoint you forget loses its metrics. Second, the format is inconsistent: some places return it in the body, some in a header, some nowhere. Third, when a client reports an error without an identifier, you cannot match that request’s log against the server log.

The solution

Middleware runs around every request before it reaches the endpoint and after the endpoint returns a response, so it is exactly the right place to attach a request-id and measure time once:

import time
import uuid
from fastapi import FastAPI, Request
from starlette.middleware.base import BaseHTTPMiddleware
app = FastAPI()
class RequestIdMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())
start = time.perf_counter()
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
response.headers["X-Process-Time"] = f"{time.perf_counter() - start:.3f}s"
return response
app.add_middleware(RequestIdMiddleware)
@app.get("/ping")
def ping():
return {"message": "pong"}

Three points are worth noting. First, if the client already sent an X-Request-ID, the middleware keeps that code instead of generating a new one — so a request can be traced across the whole system. Second, time.perf_counter() measures the true processing interval and is not skewed when the system clock changes, unlike time.time(). Third, add_middleware applies to the whole app, so endpoints written later get it automatically.

Verify it quickly with FastAPI’s built-in TestClient:

from fastapi.testclient import TestClient
client = TestClient(app)
r = client.get("/ping", headers={"X-Request-ID": "req-123"})
assert r.status_code == 200
assert r.headers["X-Request-ID"] == "req-123"
assert "X-Process-Time" in r.headers
r2 = client.get("/ping")
assert r2.headers["X-Request-ID"] # no id sent, so the server generates a UUID

Apply it to your project

Ask clients to include the X-Request-ID in every bug report, then grep that exact code in the server log — diagnosis time drops from tens of minutes of digging through logs to a few seconds. You can extend the same middleware to log every request or count requests per endpoint, while still changing code in only one place.

Related posts

Frequently asked questions

How is middleware different from a dependency in FastAPI?

A dependency runs within the scope of one endpoint and is typically used to fetch shared data such as the current user or a DB session. Middleware wraps every request of the whole app — so it suits cross-cutting work like request-id, timing, or logging.

Does the add_middleware order matter?

Yes. A middleware added later runs on the outside, meaning it runs before one added earlier. Add the timing middleware last so it covers the full processing time, including authentication time.

Why use time.perf_counter() instead of time.time()?

time.perf_counter() uses a monotonic clock, unaffected when the system clock is adjusted (for example by NTP sync). For measuring a time interval it is always correct; time.time() should only be used when you need an absolute timestamp, such as writing a timestamp into a log.

Should I use BaseHTTPMiddleware for large streaming responses?

For endpoints that return files or large streams, BaseHTTPMiddleware can hurt performance because it processes the response through a buffer layer. In that case, consider writing pure ASGI middleware for full control over the message flow.

💬 Have you tried this in your own project? If you hit an error or know a cleaner way, leave a comment below — I read them all and will fold the best ideas into the post.

Sources