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.
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.
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.
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).
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.
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.
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
});
context is the request/response for this call — the same HttpContext introduced in lesson 253.next is a delegate representing "everything registered after this point in the pipeline."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);
});
app.Use...() call in Program.cs nests 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.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.
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 under wwwroot, 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.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.
RequestDelegate is Func<HttpContext, Task> — a function that accepts the context and returns a task representing the work of handling (or passing along) the request.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.next behaves 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.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.
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.
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.
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.
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.
await next(context) = "open the next doll, wait for everything inside it to finish, then close it back up"next = the chain stops here; nothing nested inside runsProgram.cs = execution order, on the way in.next(context) continues the chain; not calling it short-circuits everything downstream, including the endpoint itself.app.Use(...) for custom logic.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?
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?
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()?
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?
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?
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.
dotnetmadeeasy.com — Learn C# and .NET, the right way.