Articles › ASP.NET Core
ASP.NET Core Middleware: How the Request Pipeline Really Works
Understand the ASP.NET Core request pipeline: how middleware runs, why order matters, Use vs Run vs Map, writing custom middleware and real-world examples like timing and correlation IDs.
Every HTTP request that reaches an ASP.NET Core application travels through a pipeline of components called middleware. Authentication, CORS, static files, routing, error handling — each is a middleware. Once you understand how this pipeline works, you can debug strange behavior quickly and add your own cross-cutting features cleanly.
The pipeline as a chain
Middleware components are chained together. Each one receives the HttpContext and a reference to the next middleware. It can:
- Do work before calling the next one,
- Call the next middleware (or decide not to — short-circuiting),
- Do work after the next one returns.
Request → [Exception Handler] → [HTTPS] → [Static Files] → [Routing] → [Auth] → [Endpoint]
Response ← [Exception Handler] ← [HTTPS] ← [Static Files] ← [Routing] ← [Auth] ← [Endpoint]
Requests flow in; responses flow back out through the same components in reverse order — like layers of an onion.
Use, Run and Map
var app = builder.Build();
// Use: do something, then call the next middleware
app.Use(async (context, next) =>
{
Console.WriteLine($"→ {context.Request.Method} {context.Request.Path}");
await next(context);
Console.WriteLine($"← {context.Response.StatusCode}");
});
// Map: branch the pipeline by path
app.Map("/health-raw", branch =>
{
branch.Run(async context => await context.Response.WriteAsync("OK"));
});
// Run: terminal middleware — never calls next
app.Run(async context => await context.Response.WriteAsync("Hello"));
Use— adds middleware that can callnext.Run— adds terminal middleware; anything registered after it never executes.Map/MapWhen— creates a branch for matching requests.UseWhen— conditionally adds middleware but rejoins the main pipeline.
Order matters — a lot
Middleware runs in the order you register it. The recommended order for a typical API:
app.UseExceptionHandler(); // 1. catch everything below
app.UseHsts(); // 2. security headers (production)
app.UseHttpsRedirection(); // 3.
app.UseStaticFiles(); // 4. short-circuits for files
app.UseRouting(); // 5. select the endpoint
app.UseCors(); // 6. after routing, before auth
app.UseAuthentication(); // 7. who are you?
app.UseAuthorization(); // 8. are you allowed?
app.UseRateLimiter(); // 9.
app.MapControllers(); // 10. execute the endpoint
Typical bugs caused by wrong ordering:
| Mistake | Symptom |
|---|---|
UseAuthorization before UseAuthentication |
every [Authorize] endpoint returns 401 |
UseCors after the endpoint or auth |
browser CORS errors on preflight requests |
| Exception handler registered late | exceptions in earlier middleware aren't handled |
| Static files after auth | public assets unexpectedly require login |
Tip: In .NET 6+,
WebApplicationaddsUseRoutingand endpoint execution implicitly, but explicit calls are still honored. When order matters (CORS, auth), be explicit.
Writing custom middleware
Convention-based middleware
public class RequestTimingMiddleware(RequestDelegate next, ILogger<RequestTimingMiddleware> logger)
{
public async Task InvokeAsync(HttpContext context)
{
var stopwatch = Stopwatch.StartNew();
context.Response.OnStarting(() =>
{
context.Response.Headers["X-Response-Time-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);
}
}
// Registration
app.UseMiddleware<RequestTimingMiddleware>();
Convention-based middleware is created once (singleton-like). Inject scoped services as parameters of InvokeAsync, not in the constructor:
public async Task InvokeAsync(HttpContext context, AppDbContext db) { ... }
Factory-based middleware with IMiddleware
public class TenantMiddleware(ITenantStore tenants) : IMiddleware
{
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
var tenantId = context.Request.Headers["X-Tenant"].ToString();
if (string.IsNullOrEmpty(tenantId) || !await tenants.ExistsAsync(tenantId))
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
await context.Response.WriteAsJsonAsync(new { message = "Unknown tenant" });
return; // short-circuit
}
context.Items["TenantId"] = tenantId;
await next(context);
}
}
builder.Services.AddScoped<TenantMiddleware>();
app.UseMiddleware<TenantMiddleware>();
IMiddleware is resolved from DI per request, so its constructor can take scoped services.
A tidy extension method
public static class RequestTimingExtensions
{
public static IApplicationBuilder UseRequestTiming(this IApplicationBuilder app) =>
app.UseMiddleware<RequestTimingMiddleware>();
}
app.UseRequestTiming();
Real-world middleware ideas
Correlation ID
Attach an id to each request so logs from different services can be connected:
app.Use(async (context, next) =>
{
var id = context.Request.Headers["X-Correlation-ID"].FirstOrDefault() ?? Guid.NewGuid().ToString("N");
context.Response.Headers["X-Correlation-ID"] = id;
using (logger.BeginScope(new Dictionary<string, object> { ["CorrelationId"] = id }))
{
await next(context);
}
});
Security headers
app.Use(async (context, next) =>
{
var h = context.Response.Headers;
h["X-Content-Type-Options"] = "nosniff";
h["X-Frame-Options"] = "DENY";
h["Referrer-Policy"] = "strict-origin-when-cross-origin";
await next(context);
});
Maintenance mode
app.UseWhen(_ => maintenance.IsEnabled, branch =>
branch.Run(async ctx =>
{
ctx.Response.StatusCode = 503;
await ctx.Response.WriteAsync("We'll be back soon.");
}));
Middleware vs filters
ASP.NET Core also has filters (action filters, exception filters, etc.) for MVC.
| Middleware | Filters | |
|---|---|---|
| Runs for | every request (including static files, minimal APIs) | MVC/Razor actions (and endpoint filters for minimal APIs) |
| Knows about | HttpContext |
action arguments, model state, action result |
| Use for | cross-cutting HTTP concerns: logging, headers, auth, CORS | action-specific concerns: validation, auditing a specific action |
Common pitfalls
- Writing to the response after
next()has started it — headers can't be modified once the body starts. UseResponse.OnStarting. - Forgetting to call
next— the request silently stops. - Reading the request body twice — call
context.Request.EnableBuffering()first and reset the stream position. - Heavy work in middleware for every request — it runs for everything, including static files and health checks.
Summary
The ASP.NET Core pipeline is a chain of middleware that each handle the request on the way in and the response on the way out. Register middleware in the right order, use Use/Run/Map deliberately, and write custom middleware for cross-cutting concerns like timing, correlation IDs and headers. Mastering this pipeline gives you precise control over every request your application handles.
FAQ
Is middleware executed for every request?
Yes, unless it's on a branch (Map, MapWhen) or an earlier middleware short-circuits the pipeline.
Where should global exception handling go?
At the very beginning of the pipeline, so it wraps all other middleware and endpoints.
Can middleware access route values?
After UseRouting(), yes: context.GetRouteValue("id") and context.GetEndpoint() are available.