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:
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:
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:
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:
['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."
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:
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:
request, before any path operation receives it. And also after the response is generated, before returning it." FastAPI's own real example:
This chapter's own synchronous version, built and run for real rather than only asserted:
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:
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
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 solutionUsing 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 solutionExtend 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 solutionChapter 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