dotnet/Ecosystem & Infrastructure
The middleware pipeline
intermediateTL;DR
var app = builder.Build();
// Each middleware wraps everything registered after it.
app.Use(async (ctx, next) =>
{
var sw = Stopwatch.StartNew();
await next(ctx); // ... the rest of the pipeline runs here ...
app.Logger.LogInformation(
"{Path} → {Status} in {Ms} ms",
ctx.Request.Path, ctx.Response.StatusCode, sw.ElapsedMilliseconds);
});
app.UseExceptionHandler("/error"); // outermost of the "real" pipeline
app.UseStaticFiles(); // short-circuits for files on disk
app.UseRouting(); // matches the request to an endpoint
app.UseAuthentication(); // who are you?
app.UseAuthorization(); // are you allowed?
app.MapControllers(); // terminal: run the endpoint
// Class-based middleware: constructor deps are effectively singleton,
// scoped services arrive as InvokeAsync parameters.
public sealed class CorrelationIdMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext ctx, AppDbContext db)
{
ctx.Response.Headers["X-Correlation-Id"] = ctx.TraceIdentifier;
await next(ctx);
}
}
How it works
The pipeline is one composed RequestDelegate
Every Use, Run, and Map* call appends a delegate to an internal list.
Build() folds that list from last to first, each one wrapping the next
— so the first middleware registered ends up as the outermost layer.
That’s the Russian doll: opening the outside layer means calling into the one
nested inside it. Code before await next(ctx) runs on the way in; code
after it runs on the way out, once every inner layer has finished — the
logging middleware in the TL;DR starts its stopwatch before the rest of the
pipeline runs and logs after it returns.
Short-circuiting
Not calling next at all is called short-circuiting — the request never
reaches anything registered afterward. UseStaticFiles does this whenever it
finds a matching file on disk; an authentication challenge does it by writing
a 401/302 directly. Once the response has started streaming, touching
Response.Headers or Response.StatusCode throws — check
Response.HasStarted if a middleware needs to know.
Routing decides the endpoint; authorization decides on it
UseRouting matches the request to an endpoint and stores it on
HttpContext — GetEndpoint() is null before UseRouting runs and
non-null after, as long as a match was found. The endpoint itself doesn’t
execute until dispatch, which is what UseEndpoints represents (explicitly
or not). Any middleware placed between routing and dispatch can inspect
the selected endpoint’s metadata (route data, [Authorize] attributes,
custom attributes) before it runs — which is exactly why UseAuthorization
has to sit in that gap: it needs to see which endpoint was selected to know
what policy to enforce, and it has to run before dispatch to actually block
it. In the minimal hosting model you rarely call UseRouting/UseEndpoints
yourself: ASP.NET Core inserts routing near the start of the pipeline and
appends endpoint dispatch at the very end, and it’ll even auto-insert
UseAuthentication/UseAuthorization right after routing if you registered
the services but never called them explicitly. That auto-wiring is a safety
net, not a substitute for controlling the order yourself.
Map branches, UseWhen branches and rejoins
Map/MapWhen split the pipeline by path or predicate — the branch replaces
the rest of the main pipeline for matching requests; it doesn’t run in
addition to it. UseWhen branches on a predicate too, but rejoins the main
pipeline afterward if the branch doesn’t short-circuit, which makes it the
right tool for “add this for a subset of requests without losing the rest of
the pipeline.”
Middleware instances live for the app, not the request
UseMiddleware<T>() constructs CorrelationIdMiddleware exactly once, at
startup, using only what’s available from the constructor (the next
delegate and anything registered in DI — resolving a scoped or transient
service there makes it captive, pinned to the app’s lifetime instead of the
request’s). Per-request, scoped dependencies — AppDbContext above — are
instead resolved fresh on every call and passed as extra InvokeAsync
parameters, because the framework invokes InvokeAsync through the current
request’s DI scope.
Gotchas
- Order bugs are the classic failure mode. In an explicitly-ordered
pipeline (the common case once you call
UseRouting/UseEndpointsyourself, e.g. in an MVC or Razor Pages app), placingUseAuthorizationafter dispatch means it never runs in time to block anything; an exception handler registered late only catches exceptions thrown by middleware after it, missing everything above. - “The response has already started.” Setting headers or the status code
after the body has begun streaming throws
InvalidOperationException— checkResponse.HasStartedbefore touching either from code that runs late in the pipeline (e.g. afterawait next(ctx)). - Forgetting
await next(ctx)silently short-circuits the request — nothing downstream ever runs, and there’s no exception to point at the mistake. - Capturing a scoped service in a middleware constructor is the captive
dependency bug again: the instance is created once at startup and reused
for every request forever. Take it as an
InvokeAsyncparameter instead. Map("/admin", ...)rebuilds a sub-pipeline from scratch. It doesn’t inherit the middleware registered after it in the main pipeline — anything shared (auth, logging) has to be re-registered inside the branch or run before theMapcall.