← Open in the full interactive course (progress tracking, search & more)

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:

THE MIDDLEWARE "ONION"
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

WRITING MIDDLEWARE WITH app.Use
1. THE BASIC SHAPE
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
});
2. CALLING next() CONTINUES THE CHAIN
3. NOT CALLING next() SHORT-CIRCUITS THE PIPELINE
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);
});
4. ORDER OF REGISTRATION = ORDER OF EXECUTION

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();
Why this exact order for routing, authentication, and authorization: Routing has to run first because authorization needs to know which endpoint is being requested — different endpoints can require different permissions, and that requirement is metadata attached to the matched endpoint. Authentication has to run before authorization for an even more basic reason: authorization answers "is this identity allowed to do this?" — but there's no identity to check permissions for until authentication has already run and established who the caller is. Reverse authentication and authorization, and you're asking "are you allowed in?" before you've even checked anyone's ID.

Analogy

Russian Nesting Dolls

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

HOW THE CHAIN IS ACTUALLY BUILT
1. EVERY MIDDLEWARE IS A RequestDelegate
2. THE PIPELINE IS COMPOSED FROM THE OUTSIDE IN
3. CALLING next() IS LITERALLY CALLING INTO THAT NESTED DELEGATE
4. SHORT-CIRCUITING IS SIMPLY "RETURN WITHOUT CALLING next()"

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:

Reach for something else when:

Mental Model

Middleware = a nested set of dolls, not a conveyor belt
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 runs

Remember:
· 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


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.


dotnetmadeeasy.com — Learn C# and .NET, the right way.