How a Request Flows Through FastAPI

Key takeaways:

  • Registration order and execution order are opposites: the middleware you add last runs first.
  • Dependencies resolve depth-first after all middleware entry code and before the path operation.
  • Your handler's return value is serialized inside the router — middleware only ever sees finished bytes.
  • Background tasks run after the body is sent; yield teardown runs after the background tasks.
  • With pure ASGI middleware the whole sequence is strictly nested and reproducible; with BaseHTTPMiddleware it is not.

Nearly every FastAPI discussion about ordering is conducted in prose. Someone asserts that middleware runs before dependencies, someone else replies that it depends, and the thread ends without evidence. This page answers the question by instrumenting an application so that it reports its own execution order, then showing what it actually reported. The conceptual model behind the trace lives in The FastAPI Request/Response Lifecycle; this page is the measurement.

Descent and ascent order of one traced FastAPI request The left column lists steps one to seven going inward: outer middleware, middle middleware, inner middleware, dependency resolution, then the path operation. The right column lists steps eight to eighteen going back outward: response start, body chunks, background task, dependency teardown, and finally the middleware exit code. Descent (inward) Ascent (outward) 01 middleware outer (added last) 02 middleware middle 03 middleware inner (added first) 04-05 dependencies, depth first 06-07 path operation returns 16-18 middleware exit code 15 yield dependency teardown 14 background task executes 11-13 final body chunk outward 08-10 response.start outward Step numbers are the real positions from the captured trace below. The ascent column reads bottom-to-top, mirroring the descent.

The scenario

You have three middleware classes and you cannot remember which one sees the request first. Or you added a BackgroundTasks job that touches the database session from a yield dependency, and it works locally but intermittently fails against a real connection pool. Or a colleague insists that response_model validation happens "after middleware", and you suspect it does not.

All three are the same question wearing different clothes: at what point in the sequence does each thing happen? A trace answers all three at once.

The instrumented application

The design constraint is that the trace must not disturb what it measures. Two decisions matter.

First, every middleware here is pure ASGI — a class with __call__(self, scope, receive, send). That is deliberate. BaseHTTPMiddleware runs the inner application inside an AnyIO task group and streams the response back through a memory object stream, which means its post-call_next code can resume before the body has finished travelling. Pure ASGI middleware has no such indirection, so "after the next layer returned" means exactly that. If you want reproducible ordering in your own application, this is the reason to prefer it — a point argued from the performance angle in Middleware Implementation.

Second, the trace is read back over a second request. There is no way to report the tail of a request inside its own response body, because the tail happens after the body is sent. So the app appends to a module-level list and exposes it at /steps.

"""A single request traced with pure-ASGI middleware only, so the ordering is unambiguous."""
from typing import Annotated

from fastapi import BackgroundTasks, Depends, FastAPI

STEPS: list[str] = []


def step(event: str) -> None:
    STEPS.append(f"{len(STEPS) + 1:02d}. {event}")


class Layer:
    """Pure ASGI middleware. No task groups, no buffering — exit really means exit."""

    def __init__(self, app, name: str) -> None:
        self.app = app
        self.name = name

    async def __call__(self, scope, receive, send) -> None:
        if scope["type"] != "http" or scope["path"] == "/steps":
            await self.app(scope, receive, send)
            return
        if self.name == "outer":
            STEPS.clear()
        step(f"middleware {self.name}: BEFORE next layer")

        async def send_wrapper(message) -> None:
            if message["type"] == "http.response.start":
                step(f"middleware {self.name}: saw response.start ({message['status']})")
            elif message["type"] == "http.response.body" and not message.get("more_body"):
                step(f"middleware {self.name}: saw final body chunk")
            await send(message)

        await self.app(scope, receive, send_wrapper)
        step(f"middleware {self.name}: AFTER next layer returned")


app = FastAPI()


async def auth() -> str:
    step("dependency auth(): resolved")
    return "user-42"


async def db(user: Annotated[str, Depends(auth)]):
    step("dependency db(): opened (depends on auth)")
    try:
        yield f"session-for-{user}"
    finally:
        step("dependency db(): closed")


def after_response() -> None:
    step("background task: executed")


@app.get("/order/{order_id}")
async def read_order(
    order_id: int,
    session: Annotated[str, Depends(db)],
    tasks: BackgroundTasks,
) -> dict[str, str]:
    step(f"path operation: running with order_id={order_id}")
    tasks.add_task(after_response)
    step("path operation: returning dict (not yet serialized)")
    return {"order_id": str(order_id), "session": session}


@app.get("/steps")
async def steps() -> dict[str, list[str]]:
    return {"timeline": list(STEPS)}


app.add_middleware(Layer, name="inner")
app.add_middleware(Layer, name="middle")
app.add_middleware(Layer, name="outer")

Note the registration block at the bottom: inner, then middle, then outer. The names describe where each one ends up, which is the reverse of the order they were written.

The captured trace

This is the real output of running the app above in-process, from _verify/examples/arch-request-flow-timeline.py:

$ GET /order/7
200 OK
{
  "order_id": "7",
  "session": "session-for-user-42"
}

$ GET /steps
200 OK
{
  "timeline": [
    "01. middleware outer: BEFORE next layer",
    "02. middleware middle: BEFORE next layer",
    "03. middleware inner: BEFORE next layer",
    "04. dependency auth(): resolved",
    "05. dependency db(): opened (depends on auth)",
    "06. path operation: running with order_id=7",
    "07. path operation: returning dict (not yet serialized)",
    "08. middleware inner: saw response.start (200)",
    "09. middleware middle: saw response.start (200)",
    "10. middleware outer: saw response.start (200)",
    "11. middleware inner: saw final body chunk",
    "12. middleware middle: saw final body chunk",
    "13. middleware outer: saw final body chunk",
    "14. background task: executed",
    "15. dependency db(): closed",
    "16. middleware inner: AFTER next layer returned",
    "17. middleware middle: AFTER next layer returned",
    "18. middleware outer: AFTER next layer returned"
  ]
}

Eighteen steps, and every disputed claim is settled by one of them.

Reading the trace

Steps 01–03 invert registration order. outer was registered last and ran first. There is no configuration involved: add_middleware prepends, and Starlette builds the ASGI chain by wrapping from the router outward, so each registration wraps everything before it. The corollary worth internalising is that adding a middleware can never accidentally exclude an existing one from observation — a new registration always sits outside. If you have ever wondered why your timing middleware reports a suspiciously low number, check whether something was registered after it.

Steps 04–05 show dependency resolution is depth first. auth() is a sub-dependency of db(), and it resolved first. FastAPI computed this tree at import time when the route was declared, so the per-request work is solving a frozen graph, not re-introspecting signatures. Nothing in the middleware layer is involved. If you need per-route logic that reads parsed inputs, this is the layer you want, and the choice is laid out in Middleware vs Dependencies: When to Use Which.

Step 07 is the sharpest result on the page. The path operation logs that it is returning a plain dict, and the very next thing any layer observes is step 08: response.start with a status code. Between those two steps FastAPI validated the return value, applied the response field, ran jsonable_encoder and produced bytes — all inside the router, invisible to every middleware. This is the empirical answer to "does middleware see my model or my JSON?" It sees JSON. It has always seen JSON. Anything you want to do with the typed object must happen in the handler or a dependency, never in middleware. What that serialization step actually does to your object is the subject of Response Model and Serialization Order.

Steps 08–13 unwind innermost first, and they unwind twice: once for the http.response.start message carrying status and headers, once for the body. This split is why header mutation must happen before response.start is forwarded — by step 11 the headers are gone. A middleware that tries to append a header after seeing the body chunk is writing to a message nobody will read. This is the single most common bug in hand-rolled tracing middleware; the correct pattern is in Implementing Custom Middleware for Request Tracing.

Step 14 confirms background tasks run after the client has its bytes. That is the entire point of BackgroundTasks, and the trace shows the guarantee is real: steps 11–13 delivered the final body chunk before step 14 fired. The client's latency does not include the task.

Step 15 is the one that ends arguments. yield dependency teardown ran after the background task, not before it. Read the practical consequence carefully: a background task that uses a database session obtained from a yield dependency happens to work in FastAPI 0.139.2, because the session is still open at step 14. But this ordering is an implementation detail of when the exit stack is unwound, not a documented contract, and it has moved between FastAPI releases. Treat a background task as a separate unit of work that acquires its own resources. The failure shapes are catalogued in When BackgroundTasks Silently Fails, and the general rules for exit-stack unwinding are in Yield Dependencies and Cleanup Order.

Steps 16–18 close the nesting. Every middleware's post-call code runs, innermost first. Because these are pure ASGI middleware, "after the next layer returned" is unambiguous and the sequence is perfectly nested against steps 01–03.

Verification: reproduce this in your own app

You do not need a special harness. Drop a module-level list into a test module, wrap your real middleware in the Layer pattern above, and assert on positions rather than on presence:

def test_middleware_registration_order(client):
    resp = client.get("/order/7")
    assert resp.status_code == 200
    timeline = client.get("/steps").json()["timeline"]
    entry = [s for s in timeline if "BEFORE next layer" in s]
    assert entry[0].endswith("outer: BEFORE next layer")
    # The tracing layer must be outermost, or it under-reports latency.

A test like this fails loudly the day somebody moves an add_middleware call in create_app(). That is worth having, because nothing else in your test suite will notice.

Trade-offs and when not to reason this way

Instrumenting the lifecycle is a debugging technique, not a design technique. Two cautions.

First, do not encode step ordering into business logic. The stable, contractual guarantees are narrow: middleware nests according to registration order; dependencies resolve before the handler; serialization happens before middleware sees the response; background tasks run after the body is sent. Everything finer than that — especially the exact position of teardown relative to background tasks — is an implementation detail that has changed and may change again.

Second, this trace used pure ASGI middleware to obtain a clean result. If your application uses BaseHTTPMiddleware, your own trace will look less tidy, with teardown interleaved among middleware exits, because the response is delivered through a stream rather than by return. That is not a bug in your instrumentation. It is a reason to prefer pure ASGI when ordering matters — and a reason not to write code that depends on the interleaving either way.

Finally, the module-level list used here is not thread-safe or concurrency-safe. Under real load, multiple requests interleave and the list becomes meaningless. Trace one request at a time, or key the events by a request ID from contextvars.

FAQ

Do background tasks run before or after yield dependency teardown? In FastAPI 0.139.2 background tasks execute first, then yield dependency teardown runs. Both happen after the response body has been fully sent to the client and before any middleware's post-call code resumes. A background task that needs a database session must therefore not rely on a session provided by a yield dependency, because the ordering is an implementation detail rather than a documented contract.

Why is the ordering messier with BaseHTTPMiddleware than with pure ASGI middleware?BaseHTTPMiddleware runs the inner application in an AnyIO task group and pipes the response through a memory object stream so call_next can return a Response object. That indirection means the inner layers resume as soon as response headers are available rather than when the body is finished, so teardown can interleave between middleware exits.

Has my response already been serialized when middleware sees it? Yes. The trace shows the path operation returning a plain dict, and the first thing any middleware observes afterwards is an http.response.start message with a status code. Validation against response_model and JSON encoding both happened inside the router before the message was emitted.

Can I make the trace deterministic in my own application? Use pure ASGI middleware rather than BaseHTTPMiddleware wherever ordering matters. Pure ASGI middleware has no task group and no buffering, so its post-call code runs exactly when the inner application returns, which produces a strictly nested and reproducible order.