ASP.NET Core Middleware Explained: The Request Pipeline
Every request goes in through the chain. The response comes back out through it too.
Lesson 253 told you that a request passes "through the middleware pipeline" on its way to your handler. That phrase is doing a lot of work, and this lesson unpacks exactly what's inside it. Middleware is one of the most important ideas in ASP.NET Core — and also one of the most commonly half-understood, because most tutorials show you that it exists without ever showing you the mental model that makes its behavior predictable.
Here's the model, stated up front: middleware isn't a one-way filter that requests pass through once. It's a chain where each link can act before handing off to the next link, and then act again after that next link is completely finished — including everything it did, all the way down to your endpoint and back. Once that clicks, middleware order stops being a mystery you memorize and becomes something you can reason about.
In this lesson, you'll learn the real execution model behind ASP.NET Core middleware — the "onion" — how to write your own with app.Use(...), tour the built-in middleware you've likely already used without knowing its name, and understand exactly why order is not a cosmetic detail but a correctness requirement.
What Is It?
The Simple Explanation
Middleware is a small piece of code that sits in the path of every request flowing through your app. Each piece of middleware gets a chance to look at (and change) the incoming request, and then it makes one of two choices: pass the request along to the next piece of middleware in line, or stop the chain right there and produce the response itself.
The Technical Definition
Formally, middleware is a component, registered in a specific order, that forms part of an application's request pipeline. Each component is represented internally as a RequestDelegate — a function that takes an HttpContext and returns a Task. Components are composed together: each one is handed a reference to the next delegate in the chain, and it decides when — or whether — to invoke it. This produces the defining property of ASP.NET Core middleware: a component can run code both before the next component executes and after it returns, because calling "next" is just an ordinary awaited function call sitting in the middle of the component's own code.
Why Does It Exist?
The Problem — Cross-Cutting Concerns Don't Belong in Every Handler
Every real web app needs a handful of things done for every single request, regardless of which endpoint it eventually reaches: logging, exception handling, redirecting HTTP to HTTPS, checking authentication, serving static files. Without a pipeline concept, you'd be forced to either duplicate that logic inside every single endpoint handler, or wire up some ad-hoc, framework-specific mechanism for each concern (which is close to what classic ASP.NET's fixed HttpModule pipeline required — heavier, and much harder to reorder or compose freely).
The Solution — A Composable Chain You Control
ASP.NET Core's answer is a plain, explicit chain of delegates that you assemble yourself, line by line, in Program.cs. There's no hidden framework magic deciding what runs when — the order you write app.Use...() calls in is the order things execute in. Any cross-cutting concern that applies broadly to many or all requests becomes one small, focused, independently testable component, instead of scattered copy-pasted logic.
Big Picture
This is the correct mental model for ASP.NET Core middleware — nested layers, like an onion or a Russian nesting doll, not a one-way conveyor belt:
Request │ ▼ ┌─────────────── Middleware A ───────────────┐ │ (before next) │ │ ┌────────────── Middleware B ───────────┐ │ │ │ (before next) │ │ │ │ ┌──────────── Middleware C ───────┐ │ │ │ │ │ (before next) │ │ │ │ │ │ ┌──────── Endpoint ────┐ │ │ │ │ │ │ │ your handler runs │ │ │ │ │ │ │ └───────────────────────┘ │ │ │ │ │ │ (after next returns) │ │ │ │ │ └──────────────────────────────────┘ │ │ │ │ (after next returns) │ │ │ └────────────────────────────────────────┘ │ │ (after next returns) │ └───────────────────────────────────────────────┘ ▲ │ Response
Notice the shape: Middleware A's "before" code runs first, then it calls into B, whose "before" code runs, which calls into C, and so on down to the endpoint. But the "after" code doesn't run in that same order — it unwinds in exactly the reverse sequence: C's after-code runs first, then B's, then A's, last. This is precisely what a nested function call does, because that's exactly what it is under the hood.
How It Works
app.Use(async (context, next) =>
{
// code here runs BEFORE the rest of the pipeline
await next(context);
// code here runs AFTER the rest of the pipeline has finished
});
contextis the request/response for this call — the sameHttpContextintroduced in lesson 253.nextis a delegate representing "everything registered after this point in the pipeline."
- Awaiting
next(context)runs every remaining middleware, then routing, then your endpoint handler — and only returns once all of that has fully completed and produced a response.
app.Use(async (context, next) =>
{
if (!context.Request.Headers.ContainsKey("X-Api-Key"))
{
context.Response.StatusCode = 401;
await context.Response.WriteAsync("Missing API key");
return; // never calls next() — nothing further in the chain runs
}
await next(context);
});
- Every middleware registered after this one — and the endpoint itself — never runs at all for this request. The response is produced right here.
- Each
app.Use...()call inProgram.csnests the next one inside it. The first one registered is the outermost layer of the onion; the last one registered sits closest to the endpoint.
Simple Example
A timing middleware that logs how long each request took — a textbook use of the "before / after" shape:
app.Use(async (context, next) =>
{
var stopwatch = Stopwatch.StartNew();
await next(context); // everything downstream runs here — routing, the endpoint, all of it
stopwatch.Stop();
Console.WriteLine($"{context.Request.Method} {context.Request.Path} → {context.Response.StatusCode} ({stopwatch.ElapsedMilliseconds}ms)");
});
What happens: the stopwatch starts, then the entire rest of the pipeline runs — every other middleware, routing, model binding, your handler, all of it — as one unit represented by that single await next(context) call. Only once everything downstream has finished and a response has been produced does execution resume on the line after it, where the elapsed time and the final status code (now correctly populated) get logged. This only works correctly because middleware runs code both before and after the chain — a plain "before only" filter couldn't know the final status code, because nothing downstream would have run yet.
Real-World Example
You've almost certainly used all of these built-in middleware components already, without ever seeing them named. Here's a representative Program.cs for a typical API, with the correct, standard order:
var app = builder.Build();
app.UseExceptionHandler("/error"); // catches unhandled exceptions from everything below it
app.UseHttpsRedirection(); // redirects plain HTTP requests to HTTPS
app.UseStaticFiles(); // serves files from wwwroot before routing even runs
app.UseRouting(); // matches the request to an endpoint (often implicit — see below)
app.UseAuthentication(); // establishes WHO the caller is
app.UseAuthorization(); // decides WHETHER that caller is allowed to proceed
app.MapControllers(); // the endpoints themselves
app.Run();
UseExceptionHandler— wraps everything below it; if any of it throws, this middleware's "after" code catches the exception on the way back up and produces an error response instead of a crash.UseHttpsRedirection— inspects the incoming request before passing it on; if it's plain HTTP, short-circuits with a redirect response instead of calling next.UseStaticFiles— if the request path matches a file underwwwroot, it short-circuits and serves the file directly, skipping routing and your endpoints entirely.UseRouting/UseAuthentication/UseAuthorization— this is the sequence that matters most, explained next.
Analogy
Open the outermost doll and there's another doll inside it — open that one and there's another, and so on, down to the smallest, solid doll in the center (your endpoint). Opening each doll is the "before" code; the solid doll in the center is where the actual work happens. Now close them back up, from the inside out: the smallest doll goes back into the next one, which goes back into the next, all the way out. That closing sequence, innermost first, is exactly the "after" code running in reverse order.
And if you decide not to open one of the dolls at all — you just stop and hand the whole nested set back — none of the dolls inside it ever get opened. That's a middleware short-circuiting: everything nested inside it, including the endpoint itself, never runs.
Under the Hood
- Internally, a
RequestDelegateisFunc<HttpContext, Task>— a function that accepts the context and returns a task representing the work of handling (or passing along) the request.
- When the app is built, each registered middleware component wraps the delegate composed from everything registered after it. This produces a single nested chain of function calls — a real, ordinary call stack, not some abstract "queue" of independent steps.
- There's no separate "pipeline engine" polling a list —
await next(context)is an ordinary awaited call into the next layer of the nested chain, which is why execution genuinely pauses at that line until everything inside it finishes, and why code after that line runs on the way back out.
- Since the chain is just nested function calls, skipping a call to
nextbehaves exactly like returning early from any nested function — everything that would have been called deeper in never executes, and control unwinds straight back through the "after" code of every middleware already entered.
Common Confusion
"Middleware runs once per request" — true, but misleading
It's tempting to picture each middleware as executing exactly one block of code and moving on, like a conveyor belt station. In reality, one middleware component's code runs in two separate moments for the same request: once on the way in (before next), and once on the way back out (after next returns) — with the entire rest of the pipeline having executed in between. The onion model, not the conveyor belt, is the accurate picture.
Does routing have to be registered explicitly?
In older ASP.NET Core code you'll often see explicit app.UseRouting() and app.UseEndpoints(...) calls, with everything endpoint-related nested inside UseEndpoints. In the modern minimal hosting model (a single top-level Program.cs, as used throughout this course), calling app.MapGet(...)/app.MapControllers() and friends is enough — the framework inserts the routing middleware at the correct point in the pipeline automatically. The underlying rule stays the same either way: whatever depends on the matched endpoint (like authorization) must still be positioned so routing has already run by the time it executes. When in doubt about a specific, less common ordering scenario, it's worth confirming against the current official ASP.NET Core middleware documentation rather than assuming.
Common Mistakes
Mistake 1 — Forgetting to call next()
Writing a middleware that's meant to just inspect or log the request, but forgetting to call await next(context) at all. Every request silently stops dead at this middleware — no endpoint, and nothing registered after it, ever runs.
Unless you deliberately intend to short-circuit (produce the entire response yourself), always call next.
Mistake 2 — UseAuthorization() before UseAuthentication()
Placing authorization before authentication in Program.cs. Authorization checks run against an identity that hasn't been established yet — requests get incorrectly rejected because the authorization system sees no authenticated user at all.
UseAuthentication() always comes before UseAuthorization() — this specific order is required and well documented, not a stylistic preference.
Mistake 3 — Writing to the response after it's already started
Trying to set a header or status code in "after next()" code, once a downstream component has already begun streaming the response body. This throws an InvalidOperationException — headers can't change after the response has started being sent.
Middleware that needs to modify response headers based on the outcome should do so carefully, checking whether the response has already started (context.Response.HasStarted) before attempting to.
When Should I Use It?
Write custom middleware when:
- The concern genuinely applies broadly — to all requests, or to a whole class of requests — regardless of which specific endpoint handles them (logging, correlation IDs, global exception handling, CORS).
- You need to act on the raw HTTP request/response, before routing has even decided which endpoint applies.
Reach for something else when:
- The concern needs to know MVC-specific details — the matched action, its bound arguments, model validation state — none of which plain middleware has access to. That's what filters exist for (lesson 255).
- The concern only applies to a handful of specific Minimal API endpoints, not the whole app — endpoint filters (lesson 256) are usually a better fit than a broad middleware component with conditional logic inside it.
Mental Model
await next(context) = "open the next doll, wait for everything inside it to finish, then close it back up"Not calling
next = the chain stops here; nothing nested inside runsRemember:
· Registration order in
Program.cs = execution order, on the way in.· Execution unwinds in exactly the reverse order, on the way out.
· Routing before Authentication before Authorization — each depends on information the one before it establishes.
Key Takeaway
- Middleware is a chain of components, each able to run code both before and after the rest of the pipeline — the "onion" model, not a one-way filter.
- Calling
next(context)continues the chain; not calling it short-circuits everything downstream, including the endpoint itself. - Exception handling, HTTPS redirection, static files, routing, authentication, and authorization are all built-in middleware — the same mechanism you use with
app.Use(...)for custom logic. - Order genuinely matters and is enforced by real dependencies between stages — most importantly, authentication must run before authorization, and routing must run before either.
- Middleware is for broad, HTTP-pipeline-wide concerns. When a concern needs MVC-specific context, filters (next lesson) are the right tool instead.
Check Your Understanding
You've seen the onion model and toured the built-in middleware you've likely already used. Let's check the mental model has stuck.
1. Three middleware components, A, B, and C, are registered in that order, followed by an endpoint. Each one prints its name before and after calling next. What's the correct output order?
Show answer
Correct: B
Why B is correct: The "before" code runs in registration order (A, then B, then C) as each one calls into the next. The endpoint runs at the center. Then the "after" code unwinds in the exact reverse order (C, then B, then A) — this is the nesting/onion model.
Why A is incorrect: This treats middleware like a one-way conveyor belt where everything runs once, in order — but each component's "after" code runs on the way back out, in reverse.
Why C, D are incorrect: These don't respect that "before" code must run in registration order, since each middleware only calls the next one after finishing its own before-code.
Reinforcement: Before-code unwinds forward, after-code unwinds backward — that's the nesting doll shape.
2. A middleware checks for an API key header and, if it's missing, writes a 401 response but never calls await next(context). What happens to the rest of the pipeline for that request?
Show answer
Correct: B
Why B is correct: Not calling next short-circuits the chain at exactly that point — everything nested inside it, both later middleware and the endpoint, never executes for this request.
Why A is incorrect: Later middleware is also nested "inside" this one in the chain — it doesn't get skipped selectively while the endpoint runs; everything downstream is skipped together.
Why C is incorrect: This is a normal, intentional short-circuit, not an error condition — no exception is thrown.
Why D is incorrect: The framework never calls next on your behalf; if your code doesn't call it, the chain simply stops.
Reinforcement: Short-circuiting is a deliberate, valid pattern — it's exactly how things like UseStaticFiles and auth failures work.
3. Why must UseAuthentication() be registered before UseAuthorization()?
Show answer
Correct: B
Why B is correct: Authorization's whole job is checking whether "this identity" is allowed to do something. If authentication hasn't run yet, there is no established identity for authorization to evaluate — the check would incorrectly fail for everyone.
Why A is incorrect: Relative performance has nothing to do with why the order is required.
Why C is incorrect: This is a real, functional requirement documented by Microsoft — reversing the order breaks authorization for authenticated users.
Why D is incorrect: This isn't a Kestrel-level requirement; it's a logical dependency between the two middleware components themselves.
Reinforcement: You can't check permissions for an identity that hasn't been established yet.
4. A timing middleware wraps await next(context) and logs context.Response.StatusCode immediately afterward. Why does this correctly log the final status code, rather than some default/unset value?
Show answer
Correct: B
Why B is correct: await next(context) doesn't return until the entire nested chain below it — later middleware, routing, and the endpoint — has completed. By the time execution resumes on the next line, the endpoint has already set the real status code on the shared HttpContext.
Why A is incorrect: There's no separate "refresh" — it's the same HttpContext object throughout, mutated by whatever ran inside next.
Why C is incorrect: This is a completely standard, correct, and common middleware pattern.
Why D is incorrect: Middleware doesn't poll anything — it's ordinary sequential async code, just structured as nested calls.
Reinforcement: This is the entire reason the "after next()" half of middleware is useful — it runs with full knowledge of what happened downstream.
5. You need a cross-cutting concern that has to inspect which specific Controller action is about to run, and its bound, validated arguments. Is plain middleware the right tool?
Show answer
Correct: B
Why B is correct: Middleware works with the raw HttpContext and has no built-in concept of "the action about to run" or "its bound arguments" — those are MVC-specific concepts. Filters, covered in the next lesson, run inside the MVC action-invocation pipeline and have exactly that context available.
Why A, C are incorrect: Even registered after routing, middleware still only sees the matched Endpoint metadata in general terms, not the rich, MVC-specific action-execution context (bound arguments, model state, the controller instance) that filters get.
Why D is incorrect: It's entirely achievable — just not with middleware. Filters (lesson 255) exist precisely for this.
Reinforcement: Middleware = whole-pipeline, MVC-agnostic. Filters = MVC-pipeline-aware, with richer context. That distinction is exactly what the next lesson builds on.
You now understand the onion model that governs every request in ASP.NET Core — and exactly why middleware order isn't negotiable.