Middleware & the Request/Response Lifecycle: How It Actually Works

Web Framework Internals

Chapter 8 ยท Middleware & the Request/Response Lifecycle: How It Actually Works

Every request this course has built so far passes through a router (Chapter 2), maybe a template engine (Chapter 4), maybe an ORM (Chapter 6) — but real frameworks also let arbitrary code run before and after every request, regardless of which route matched: logging, authentication, timing, CORS headers, error handling. That layer is middleware. This chapter builds a real middleware chain from scratch and verifies, directly, the exact execution order every real framework's own documentation describes.

The Basic Job: A Chain of Functions Wrapping a Handler

A middleware is a function that receives the request and a reference to "whatever comes next" — either another middleware, or the real handler at the very center of the chain. Composing a list of them means nesting each one inside the next:

def build_chain(middlewares, handler): chain = handler for middleware in reversed(middlewares): chain = (lambda mw, nxt: lambda request: mw(request, nxt))(middleware, chain) return chain def middleware_a(request, next): trace.append("before A") response = next(request) trace.append("after A") return response # middleware_b, middleware_c follow the identical shape def handler(request): trace.append("handler") return "OK" app = build_chain([middleware_a, middleware_b, middleware_c], handler) app({"path": "/"}) print(trace)
Verified directly — the real execution order is genuinely nested, not sequential
trace comes out as ['before A', 'before B', 'before C', 'handler', 'after C', 'after B', 'after A']. Each middleware's own "after" code runs in the exact reverse of registration order — A registers first and finishes last, because A's own call to next(request) is what invokes everything nested inside it, and control only returns to A's own "after" code once every one of those inner calls has fully completed.

Django's Own Name for This: "The Onion Model"

That nested structure isn't an implementation detail specific to this chapter's own toy version — it's a real, named model, described in exactly those words by Django's own documentation:

Django's own documentation, quoted directly
"You can think of it like an onion: each middleware class is a 'layer' that wraps the view, which is in the core of the onion. If the request passes through all the layers of the onion (each one calls get_response to pass the request in to the next layer), all the way to the view at the core, the response will then pass through every layer (in reverse order) on the way back out." Django's own real middleware class follows the identical shape as this chapter's own build_chain(), one class per layer instead of one function:

class SimpleMiddleware: def __init__(self, get_response): self.get_response = get_response # the next layer, captured once def __call__(self, request): # code here runs BEFORE the view (and later middleware) response = self.get_response(request) # code here runs AFTER the view return response

Per Django's own docs, get_response "might be the actual view (if this is the last listed middleware) or it might be the next middleware in the chain. The current middleware doesn't need to know or care what exactly it is, just that it represents whatever comes next" — the identical property this chapter's own next parameter has: every middleware function is written the same way, whether the thing it calls next is another middleware or the real handler.

Short-Circuiting: When a Middleware Never Calls next()

Nothing forces a middleware to actually call the next layer. An authentication check is the classic real reason not to — if a request isn't authenticated, there's no reason to let it reach the real handler, or any middleware registered after the auth check, at all:

def auth_middleware(request, next): if not request.get("authenticated"): return "401 Unauthorized" # next() is never called return next(request) app2 = build_chain([auth_middleware, logging_middleware], handler2) app2({"path": "/", "authenticated": False})
Verified directly — the logging middleware and the real handler genuinely never run
For an unauthenticated request, the real trace comes out as exactly ['auth check', 'auth REJECTED -- short-circuiting'] — logging_middleware and handler2 never execute at all, confirmed by their own trace entries being completely absent. Rerunning the identical chain with authenticated: True produces the full real trace, ['auth check', 'before logging', 'handler2 ...', 'after logging', 'after auth'] — the exact same code path, genuinely branching on nothing but that one boolean. Django's own documentation names this precisely: "If one of the layers decides to short-circuit and return a response without ever calling its get_response, none of the layers of the onion inside that layer (including the view) will see the request or the response."

The Real Cost of Forgetting to Call next()

Short-circuiting on purpose is a real feature. Forgetting to call next() at all — not returning a response either — is a real, easy-to-make bug, and Express's own documentation names the exact consequence directly: "If the current middleware function does not end the request–response cycle, it must call next() to pass control to the next middleware function. Otherwise, the request will be left hanging."

def broken_middleware(request, next): print("ran, but forgot to call next() or return anything") # no call to next(request); no return statement at all app3 = build_chain([broken_middleware], handler3) result = app3({"path": "/"}) print(result)
Verified directly — the chain genuinely produces nothing
handler3's own print statement never fires, and result comes back as None — a real, working (if minimal) reproduction of Express's own documented failure mode. This chapter's synchronous chain returns immediately with nothing rather than literally hanging forever, but the underlying real consequence is identical either way: whatever called this chain never gets the response it was actually waiting for, because nothing downstream of the broken middleware was ever reached, and the broken middleware itself never produced one either.

Mutating the Request and the Response

Two real, distinct capabilities fall out of the exact same structure. A middleware that runs code before calling next() can attach new data to the request that every downstream layer, including the real handler, can then read:

def inject_user_middleware(request, next): request["user"] = {"id": 42, "name": "Sam"} return next(request) def handler4(request): return f"Hello, {request['user']['name']} (id={request['user']['id']})" app4 = build_chain([inject_user_middleware], handler4) print(app4({"path": "/"})) # Hello, Sam (id=42)

A middleware that runs code after next() returns can inspect or modify the real response on its way back out — the exact real example FastAPI's own documentation uses to introduce middleware at all is a timing header, added after the real handler has already produced its response:

FastAPI's own documentation, quoted directly — including its own real code example
"You can add code to be run with the request, before any path operation receives it. And also after the response is generated, before returning it." FastAPI's own real example:
@app.middleware("http") async def add_process_time_header(request: Request, call_next): start_time = time.perf_counter() response = await call_next(request) process_time = time.perf_counter() - start_time response.headers["X-Process-Time"] = str(process_time) return response

This chapter's own synchronous version, built and run for real rather than only asserted:

def timing_middleware(request, next): start = time.perf_counter() response = next(request) elapsed = time.perf_counter() - start response.headers["X-Process-Time"] = f"{elapsed:.6f}" return response def slow_handler(request): time.sleep(0.05) return Response("done") app5 = build_chain([timing_middleware], slow_handler) result = app5({"path": "/"}) print(result.headers) # {'X-Process-Time': '0.050091'}
Verified directly — a real, accurately-measured elapsed time, independently confirming FastAPI's own real example
slow_handler genuinely sleeps for 0.05 real seconds; the measured header comes back as 0.050091 — accurate to within fractions of a millisecond of real overhead. Wrapping time.perf_counter() around next(request) works identically whether "next" is another middleware, a real database query (Chapter 6), or a template render (Chapter 4) — the timing middleware has no idea what it's timing, and doesn't need to.

Error-Handling Middleware: Catching Exceptions From Inner Layers

Since every middleware's own call to next() is an ordinary function call, an exception raised deep inside the chain propagates upward through every enclosing layer exactly like any other Python exception — which means a middleware can catch it with an ordinary try/except wrapped around that one call:

def error_handling_middleware(request, next): try: return next(request) except ValueError as e: return Response(f"500 Internal Server Error: {e}") def crashing_handler(request): raise ValueError("something genuinely went wrong") app6 = build_chain([error_handling_middleware], crashing_handler) print(app6({"path": "/"}).body) # 500 Internal Server Error: something genuinely went wrong
Verified directly, both with and without the error-handling middleware present
With error_handling_middleware wrapping the chain, the real ValueError raised deep inside crashing_handler is caught and converted into a genuine, ordinary Response object — the caller never sees an exception at all. Rebuilding the identical chain with error_handling_middleware left out, and calling crashing_handler directly, confirmed the exact same exception really does propagate all the way out uncaught. Nothing here is special-cased for errors specifically — a real try/except around one ordinary function call is the entire mechanism.

Where This Course Is Headed

Chapter 9 checks how Express, Django, Rails, and FastAPI each actually implement everything built in this chapter — function-based vs. class-based middleware, synchronous vs. async chains, and each framework's own real, documented way of registering middleware in a specific order.

Hands-On Exercises

Exercise 1

Add a CORS-header middleware to this chapter's own build_chain() system that adds an Access-Control-Allow-Origin: * header to every response after next() returns, and verify it against a real handler by checking the returned Response object's own headers dict.

๐Ÿ“„ View solution
Exercise 2

Using this chapter's own auth_middleware and logging_middleware, swap their registration order (logging first, then auth) and trace what actually changes for an unauthenticated request compared to the chapter's own original order โ€” specifically, does the logging middleware's own "before" code still run before the rejection happens?

๐Ÿ“„ View solution
Exercise 3

Extend this chapter's own error_handling_middleware to also catch a second, different exception type (e.g. a custom NotFoundError) and return a distinct "404 Not Found" response for it while still returning "500 Internal Server Error" for a ValueError, and verify both cases against two separate crashing handlers.

๐Ÿ“„ View solution

Chapter 8 Quick Reference

  • The basic job โ€” each middleware wraps "whatever comes next," composed into one nested call chain
  • The real onion order โ€” verified as before-A/before-B/before-C/handler/after-C/after-B/after-A; Django's own docs name this exact model "an onion" directly
  • Short-circuiting โ€” a middleware can return a response without ever calling next(), verified stopping every inner layer from running at all
  • Forgetting to call next() โ€” verified producing no response at all, matching Express's own documented "the request will be left hanging" warning
  • Request/response mutation โ€” before next(), attach data for downstream code; after next(), inspect or modify the real response โ€” verified with a real, measured timing header reproducing FastAPI's own real X-Process-Time example
  • Error-handling middleware โ€” an ordinary try/except around one call to next(), verified catching a real exception the same chain lets propagate when that middleware is removed
  • Next chapter: Middleware compared across Express, Django, Rails & FastAPI