The ASP.NET Core Middleware Pipeline: Ordering, Use, Run, Map and Your Own Middleware
We follow a request through the middleware rings and the response back out: Use, Run and Map, writing middleware as a class, the effect of ordering, the recommended order of built-in middleware and typical pitfalls.
Version note: The code in this post is written for .NET 6 and runs unchanged on .NET 10.
Guess First
You send a single request to the application below. Which lines are written to the console, and in what order? Write it down, then read on.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.Use(async (context, next) =>
{
Console.WriteLine("A: before");
await next();
Console.WriteLine("A: after");
});
app.Use(async (context, next) =>
{
Console.WriteLine("B: before");
await next();
Console.WriteLine("B: after");
});
app.Run(async context =>
{
Console.WriteLine("C: handling");
await context.Response.WriteAsync("Hello from the end of the pipeline");
});
app.Run();
The Pipeline: Nested Rings
When I ran the application and sent curl http://localhost:5214/anything, the answer was "Hello from the end of the pipeline", and the console showed this order:
A: before
B: before
C: handling
B: after
A: after
If your guess ended with "A, B, C", you got half of it. A middleware is a function that receives the request, does something if it wants to, and hands it to the next one with next(). By the time the await next() line returns, everyone after it has finished their work. So the request flows from A to C, and the response travels back from C to A. Every middleware gets a second chance to touch the response.
Think of nested Russian dolls. The request goes in by opening the outermost doll, the response is produced in the innermost one, and then the dolls close one by one on the way out. Almost everything in ASP.NET Core is one of these rings: HTTPS redirection, authentication, exception handling, routing and, at the very end, the endpoint that runs your controller.
Use, Run and Map
You build the pipeline with three basic methods. Use adds a ring; if it calls next, the request continues, if not, it stops there. Run is a terminal ring: it does not even have a next parameter, the pipeline ends there. Map branches the pipeline for a given path. The ProductCatalog API's Program.cs for this post uses all three:
using ProductCatalog.Api.Middleware;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/error");
app.UseHsts();
}
app.UseRequestTiming();
app.Use(async (context, next) =>
{
if (context.Request.Headers.ContainsKey("X-Maintenance"))
{
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
await context.Response.WriteAsync("Catalog is under maintenance");
return;
}
await next(context);
});
app.Map("/ping", branch =>
{
branch.Run(async context => await context.Response.WriteAsync("pong"));
});
app.UseHttpsRedirection();
app.UseRouting();
app.UseAuthorization();
app.MapControllers();
app.Map("/error", () => Results.Problem());
app.Run();
The second ring is a maintenance mode: if the request has an X-Maintenance header, it returns 503 and leaves without calling next. This is called short-circuiting; the request never visits the rest of the pipeline. Here we used the next(context) form. This overload, which came with .NET 6, takes a RequestDelegate and is the recommended form; the next() form keeps working too.
app.Map("/ping", ...) sends the request into its own small pipeline if the path starts with /ping, and it never returns to the main line. Careful: app.Map("/error", ...) at the end of the file carries the same name but is something else. Because it takes a function rather than a pipeline, it is an endpoint definition; we will clear up the difference in the routing post.
Our Own Middleware: As a Class
Middleware written as a lambda clutters Program.cs once it grows beyond a few lines. Middleware that does real work is written as a class. Here is one that measures the duration of every request:
using System.Diagnostics;
namespace ProductCatalog.Api.Middleware;
public class RequestTimingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestTimingMiddleware> _logger;
public RequestTimingMiddleware(RequestDelegate next, ILogger<RequestTimingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var stopwatch = Stopwatch.StartNew();
context.Response.OnStarting(() =>
{
context.Response.Headers["X-Elapsed-Ms"] = stopwatch.ElapsedMilliseconds.ToString();
return Task.CompletedTask;
});
await _next(context);
_logger.LogInformation("{Method} {Path} -> {StatusCode} in {Elapsed} ms",
context.Request.Method, context.Request.Path, context.Response.StatusCode, stopwatch.ElapsedMilliseconds);
}
}
namespace ProductCatalog.Api.Middleware;
public static class RequestTimingMiddlewareExtensions
{
public static IApplicationBuilder UseRequestTiming(this IApplicationBuilder app)
{
return app.UseMiddleware<RequestTimingMiddleware>();
}
}
A class does not need to implement an interface to be middleware; the rules are these: it takes a RequestDelegate in its constructor and has an InvokeAsync (or Invoke) method that takes an HttpContext and returns a Task. You can inject other services such as ILogger into the constructor too. The extension method is just a convenience; it gives your own middleware the same UseXxx look as the built-in ones.
We will see in the pitfalls section why we write the header inside OnStarting instead of directly. When it runs, the header shows up in the response and the log shows up in the console:
$ curl -i -k https://localhost:7278/api/products
HTTP/2 200
content-type: application/json; charset=utf-8
date: Tue, 03 Nov 2026 21:18:49 GMT
server: Kestrel
x-elapsed-ms: 13
["Mechanical Keyboard","Wireless Mouse"]
info: ProductCatalog.Api.Middleware.RequestTimingMiddleware[0]
GET /api/products -> 200 in 16 ms
The gap between 13 in the header and 16 in the log is no accident. The header was written when the response started; the log, after the response had been sent completely.
Why Ordering Matters So Much
Middleware runs in the order it is added in Program.cs, and that order directly changes behavior. I sent two requests to the same application over HTTP:
$ curl -i http://localhost:5214/ping
HTTP/1.1 200 OK
Date: Tue, 03 Nov 2026 21:18:48 GMT
Server: Kestrel
Transfer-Encoding: chunked
X-Elapsed-Ms: 0
pong
$ curl -i http://localhost:5214/api/products
HTTP/1.1 307 Temporary Redirect
Content-Length: 0
Date: Tue, 03 Nov 2026 21:18:48 GMT
Server: Kestrel
Location: https://localhost:7278/api/products
X-Elapsed-Ms: 0
/ping answered over HTTP, while /api/products was redirected to HTTPS. That is because the /ping branch is defined before UseHttpsRedirection; the request never reached it. The maintenance mode also runs before the HTTPS redirect for the same reason. If you want a health check to answer over HTTP, this order works for you; but make the same mistake before authorization and you open an endpoint you meant to protect to everyone.
That is also why we put RequestTimingMiddleware first. It measures the duration of everything that comes after it; every request, including the 307 and 503 responses, ends up in the log. Had we put it last, it would never have seen redirected requests or requests caught by maintenance mode.
Built-in Middleware and the Recommended Order
These are the built-in middleware you will use most in a Web API, in their recommended order. UseExceptionHandler comes first, because it has to be the outermost ring to catch every exception thrown after it. Then come UseHsts and UseHttpsRedirection. Next, UseRouting decides which endpoint the request will go to. UseCors, UseAuthentication and UseAuthorization need to know that decision, so they run after routing and before the endpoint. At the very end, the endpoints you defined with MapControllers are executed.
In the example we turned on the exception handler for Production. When a controller throws an exception, the middleware re-executes the request against the /error endpoint, and the Results.Problem() there produces a 500 ProblemDetails response. In Development, the developer exception page that .NET 6 adds automatically is in charge. We will cover error handling in full detail in a separate post.
The WebApplication in .NET 6 gives you one more convenience. If you never call UseRouting, it adds it at the very start of the pipeline itself, and it runs the endpoints you defined with Map... at the end of the pipeline. That is why the template has no UseRouting line. In the example we wrote it explicitly, because we want our own middleware to run before routing; wherever you call it is where routing sits in the pipeline.
Middleware such as rate limiting and output caching came with .NET 7; we will see them in the version posts.
Common Pitfalls
Writing a header after the response has started. The middleware below compiles but blows up on the first request:
app.Use(async (context, next) =>
{
await next();
context.Response.Headers["X-Served-By"] = "catalog-01";
});
This exception shows up in the log: System.InvalidOperationException: Headers are read-only, response has already started. By the time next() returns, the body has started being written and the headers have long since gone to the client. The fix is to write the header in the OnStarting callback:
app.Use(async (context, next) =>
{
context.Response.OnStarting(() =>
{
context.Response.Headers["X-Served-By"] = "catalog-01";
return Task.CompletedTask;
});
await next();
});
Injecting a scoped service into a middleware constructor. A class middleware is created once when the application starts and lives for the whole lifetime of the application. If you put a service registered with AddScoped into its constructor, the application does not even start in Development: Cannot resolve scoped service 'ProductCatalog.Api.Middleware.AuditLog' from root provider. In Production, scope validation is off, so the application starts and that service silently turns into a single instance shared by every request; that is even worse. Give the scoped service to InvokeAsync as a parameter instead of the constructor:
public class AuditMiddleware
{
private readonly RequestDelegate _next;
public AuditMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context, AuditLog auditLog)
{
auditLog.Entries.Add(context.Request.Path);
await _next(context);
}
}
Putting app.Run in the wrong place. A terminal Run ring never runs any middleware added after it. In a project where controllers do not answer, the first thing to look for may be an app.Run(...) forgotten in the middle of the pipeline.
Confusing middleware with filters. Middleware sees every request but does not know which action will run; for MVC-specific work there are filters, and we will see them in a separate post.
A Small Experiment
★☆☆ Add a third Use to the Guess First example, but make it not call next(). Watch how the console output and the response change.
★★☆ Move RequestTimingMiddleware below UseHttpsRedirection and call /api/products over HTTP. Check whether the 307 response ends up in the log.
★★★ Write a class middleware that writes the incoming X-Correlation-Id request header to the response, or a new Guid if there is none. Add the header with OnStarting and register it as app.UseCorrelationId() through an extension method.
Somewhere in the middle of the pipeline, UseRouting looks at the request and decides "this request goes to that endpoint". In the next post we open up how that decision is made: endpoint routing, attribute routing, route templates, constraints and conflicting routes.