Module 1 · Hosting and the Request Pipeline · Lesson 2 of 18
The Request Pipeline: Middleware Order, Short-Circuits and Branches
Learning outcome
You can predict the exact order in which middleware runs on the way in and on the way out, explain how Use, Run, Map, MapWhen and UseWhen differ, say why a header cannot be added after the body has started, and order a production pipeline correctly from memory.
Intuition
The pipeline is an onion. Each middleware gets the HttpContext and a next delegate. Code before await next(ctx) runs on the way in, code after it runs on the way out, in reverse order. A middleware that does not call next short-circuits: everything after it is skipped, but everything before it still runs its "out" half.
flowchart LR
R["request"] --> A["A: in"] --> B["B: in"] --> C["C: in"] --> E["endpoint"]
E --> C2["C: out"] --> B2["B: out"] --> A2["A: out"] --> S["response"]
B -. "short-circuit (no next)" .-> B2Deep dive
How the pipeline is built
app.Use(...) does not run anything. It adds a Func<RequestDelegate, RequestDelegate> to a list. When the host starts, ApplicationBuilder.Build() walks that list backwards, wrapping each component around the next, and ends with a default terminal delegate that returns 404. The result is one RequestDelegate that Kestrel calls per request. That is why registration order is execution order.
The building blocks
| API | Calls next? | Rejoins main pipeline? | Typical use |
|---|---|---|---|
Use | Optional | Yes | Most middleware |
Run | Never (terminal) | No | Final handler |
Map("/path", ...) | Branch has its own pipeline | No | Path-based sub-app |
MapWhen(predicate, ...) | Branch has its own pipeline | No | Condition-based sub-app |
UseWhen(predicate, ...) | Branch can call next or short-circuit | Yes, unless the branch short-circuits | Conditional extra middleware |
MapGet/MapPost... | Endpoint (routing) | n/a | Your API handlers |
The response-started rule
HTTP sends the status line and headers before the body. Once the first body byte is written, Response.HasStarted becomes true and headers and status code are frozen. Setting them later throws InvalidOperationException. To add a header "at the end", register a callback with Response.OnStarting(...), which runs just before the headers are sent.
A production order worth memorising
The order below follows the Microsoft guidance for an API with authentication:
UseExceptionHandler(or the developer exception page in Development): first, so it wraps everything.UseHsts, thenUseHttpsRedirection.UseStaticFiles(if any): early, so files skip auth and routing.UseRouting: selects the endpoint.UseCors: after routing so endpoint CORS policies are visible, before auth.UseAuthentication, thenUseAuthorization.UseRateLimiter,UseOutputCache, custom middleware that needs the endpoint or the user.- Endpoints (
MapControllers,MapGet...).
The free TIN ASP.NET Core Middleware Order Cheat Sheet has the full table; this lesson explains why the order is what it is.
Code example: trace the onion
// L02: middleware is an onion. Order of registration = order of execution on the way in, reversed on the way out.
var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
builder.WebHost.UseUrls("http://127.0.0.1:0");
var app = builder.Build();
var log = new List<string>();
app.Use(async (ctx, next) => // A: wraps everything after it
{
log.Add("A in");
await next(ctx);
log.Add($"A out (status {ctx.Response.StatusCode})");
});
app.Use(async (ctx, next) => // B: short-circuits /blocked
{
if (ctx.Request.Path.StartsWithSegments("/blocked"))
{
log.Add("B short-circuit 403");
ctx.Response.StatusCode = 403;
return; // next() never called: C and the endpoint never run
}
log.Add("B in");
await next(ctx);
log.Add("B out");
});
app.UseWhen(ctx => ctx.Request.Query.ContainsKey("trace"), // branch that REJOINS the main pipeline
branch => branch.Use(async (ctx, next) => { log.Add("UseWhen branch"); await next(ctx); }));
app.Map("/health", branch => branch.Run(async ctx => // branch that does NOT rejoin
{
log.Add("Map(/health) terminal");
await ctx.Response.WriteAsync("healthy");
}));
app.Use(async (ctx, next) => // C: tries to add a header AFTER the body was written
{
log.Add("C in");
await next(ctx);
try { ctx.Response.Headers["X-Too-Late"] = "1"; log.Add("C out: header added"); }
catch (InvalidOperationException) { log.Add($"C out: header rejected, HasStarted={ctx.Response.HasStarted}"); }
});
app.MapGet("/hello", () => { log.Add("endpoint /hello"); return "hi"; });
await app.StartAsync();
using var http = new HttpClient { BaseAddress = new Uri(app.Urls.First()) };
foreach (var path in new[] { "/hello", "/blocked", "/health", "/hello?trace=1" })
{
log.Clear();
var res = await http.GetAsync(path);
await Task.Delay(100); // let "out" steps finish after the client has the response
Console.WriteLine($"GET {path} -> {(int)res.StatusCode}");
Console.WriteLine(" " + string.Join(" | ", log));
}
await app.StopAsync();Output
GET /hello -> 200 A in | B in | C in | endpoint /hello | C out: header rejected, HasStarted=True | B out | A out (status 200) GET /blocked -> 403 A in | B short-circuit 403 | A out (status 403) GET /health -> 200 A in | B in | Map(/health) terminal | B out | A out (status 200) GET /hello?trace=1 -> 200 A in | B in | UseWhen branch | C in | endpoint /hello | C out: header rejected, HasStarted=True | B out | A out (status 200)
Code walkthrough
/hello: A, B, C in; endpoint; C, B, A out. The reverse order on the way out is the onion.- C tried to add a header after
nextreturned. The endpoint had already written"hi", soHasStarted=Trueand the header was rejected. UseOnStartinginstead (Practice set, E10). /blocked: B returned without callingnext. C and the endpoint never ran, but A still ran its "out" half and saw status 403./health:Mapcreated a separate branch with its own terminalRun. C never ran: aMapbranch does not rejoin the main pipeline./hello?trace=1:UseWhenran its extra middleware and then rejoined: C and the endpoint still ran.
Summary table
| Behaviour | Rule |
|---|---|
| Execution order | Registration order on the way in, reverse on the way out |
| Short-circuit | Do not call next; earlier middleware still runs its out half |
Map/MapWhen | Separate branch; does not come back |
UseWhen | Conditional detour; rejoins unless the branch short-circuits |
| Headers after body started | Throws; use Response.OnStarting |
| No endpoint matched | Pipeline falls through to the terminal 404 |
Failure modes
- Exception handler registered late: exceptions thrown by middleware before it are not caught (Practice set, E4).
- Authorization before authentication: policies relying on the default-scheme user can see an anonymous principal and return 401 (Practice set, E1). Policies with explicit authentication schemes can authenticate during authorization; keep the recommended order.
- CORS after authorization: preflight requests are rejected before the CORS policy can answer them.
- Writing to the response and then calling
next: later middleware cannot change headers or status. - Using
Mapwhen you meantUseWhen: the rest of your pipeline silently stops running for that path.
Interview angle
"Why does order matter, and what is your middleware order?" Say that the pipeline is built once, backwards, into a single delegate, so order is execution order. Then give the order with a reason for each step: exception handler first because it must wrap everything; routing before CORS and auth because they read endpoint metadata; authentication before authorization because authorization needs the user. Finish with the HasStarted rule. Interviewers like candidates who know why headers cannot change after the body starts.
Practice questions
- Middleware A logs before and after
next; B short-circuits. Which lines does A log?
Answer: Both. A's "in" runs, B returns without calling next, then A's "out" runs with B's status code.
- What is the difference between
app.Map("/admin", ...)andapp.UseWhen(ctx => ctx.Request.Path.StartsWithSegments("/admin"), ...)?
Answer: Map creates a branch that never returns to the main pipeline (and strips the matched path prefix into PathBase). UseWhen runs extra middleware and continues down the main pipeline unless its branch short-circuits.
- How do you add a response header that contains the time until headers are sent?
Answer: Start a stopwatch and register Response.OnStarting to set the header just before headers are sent. This is not total processing time for a streaming response; record completion duration separately in logs or metrics. Setting the header after await next() fails if the body has started.
- Where does
UseCorsgo and why?
Answer: After UseRouting (so endpoint-specific CORS metadata is visible) and before UseAuthentication/UseAuthorization (so preflight requests are answered before auth rejects them).
Revision checklist
- Pipeline = list of
Func<RequestDelegate, RequestDelegate>, composed backwards into one delegate. - In-order on the way in, reverse on the way out.
Runis terminal;Map/MapWhenbranch away;UseWhenrejoins unless its branch short-circuits.- Headers freeze when the body starts:
OnStarting. - Exception handler first; routing, CORS, authentication, authorization, then endpoint-aware middleware.
Related on TechInterviewNotes
- Free note: How an ASP.NET Core Request Actually Flows (overview).
- Free note: Production Incident Triage for ASP.NET Core APIs for ordering bugs seen in production.
Analogy
Airport security lanes
Each checkpoint (middleware) inspects you on the way to the gate and can send you back (short-circuit). A Map branch is a separate terminal: once you are sent there, you never return to the main hall. UseWhen is the extra bag check: you go through it and then rejoin the same queue. And once you have boarded (the body has started), nobody can change your boarding pass (headers).
Quick reference
Middleware rules
- Order of registration = order of execution in; reverse out.
- No
nextcall = short-circuit. Mapbranches away;UseWhencomes back unless its branch short-circuits.Response.HasStartedtrue = headers and status frozen; useOnStarting.- Exception handler, HSTS/HTTPS, static files, routing, CORS, authentication, authorization, rate limiting/output cache, endpoints.