ASP.NET Core Middleware Pipeline
Every HTTP request passes through a chain of middleware β order matters more than you think.
The ASP.NET Core middleware pipeline is a series of components assembled at startup. Each component receives an HttpContext, can inspect and modify the request, optionally calls the next component, then can inspect and modify the response on the way back. Think of it as an onion β request goes in through each layer, response comes back through those same layers in reverse.
When you call app.Use*, app.Run, or app.Map, ASP.NET Core builds a linked chain of RequestDelegate functions. Each middleware wraps the next one β like nested Russian dolls. The chain is compiled once and reused for every request.
An HTTP request enters the first middleware. That middleware does its pre-processing, then calls 'await next(context)' to pass control to the next one. This continues until a terminal middleware (app.Run or an endpoint) generates a response.
After the terminal middleware writes the response, control returns up the chain. Each middleware can inspect or modify the response on the way out. This is how response compression and caching headers work β they run on the way back.
Middleware can return early without calling next(). Authorization middleware does this for 401/403 responses. Static files middleware does this when it finds a matching file β the request never reaches your controllers.
app.Use adds middleware that calls next(). app.Run adds terminal middleware that never calls next() β it ends the pipeline. app.Map branches the pipeline based on path prefix, creating a sub-pipeline for matched requests.
Key Concepts
The compiled type of a middleware chain: Func<HttpContext, Task>. Each app.Use* call wraps the existing delegate in a new one.
Interface with InvokeAsync(HttpContext, RequestDelegate). Prefer this over the convention-based approach β it supports DI constructor injection and is testable.
Returning from InvokeAsync without calling next(context). Stops the request from reaching further middleware. The response path still executes for outer middleware.
UseRouting resolves which endpoint matches the request (populates IEndpointFeature). UseAuthorization needs this to read [Authorize] metadata. Put Authorization AFTER Routing.
app.Run() and mapped endpoints. Never call next() β they generate the response. Every pipeline must end with one or requests will get an empty 200 response.
CORS before Auth, ExceptionHandler first, Routing before Authorization. The order isn't a preference β wrong order causes production bugs that are hard to diagnose.
1// Program.cs β order is everything2var app = builder.Build();34// MUST be first β catches unhandled exceptions from all subsequent middleware5app.UseExceptionHandler("/error");67// HTTPS redirect before anything else reads the request8app.UseHttpsRedirection();910// CORS must be before Auth β preflight OPTIONS requests11// must be handled before authorization checks run12app.UseCors("AllowFrontend");1314// Auth before Routing? No. Routing must resolve the endpoint first15// so [Authorize] metadata is available to UseAuthorization16app.UseRouting();1718app.UseAuthentication(); // Populates HttpContext.User19app.UseAuthorization(); // Checks [Authorize] on the matched endpoint2021// Custom middleware β accesses both the resolved route AND the identity22app.UseMiddleware<RequestLoggingMiddleware>();2324app.MapControllers();2526// ----------------------------------------------------------------27// Custom middleware using the IMiddleware interface (DI-friendly)28public class RequestLoggingMiddleware : IMiddleware29{30 private readonly ILogger<RequestLoggingMiddleware> _logger;3132 public RequestLoggingMiddleware(ILogger<RequestLoggingMiddleware> logger)33 => _logger = logger;3435 public async Task InvokeAsync(HttpContext context, RequestDelegate next)36 {37 var sw = Stopwatch.StartNew();38 try39 {40 await next(context); // Call next middleware in pipeline41 }42 finally43 {44 sw.Stop();45 _logger.LogInformation(46 "{Method} {Path} responded {StatusCode} in {Elapsed}ms",47 context.Request.Method,48 context.Request.Path,49 context.Response.StatusCode,50 sw.ElapsedMilliseconds);51 }52 }53}
Middleware order causes more production incidents than almost any other ASP.NET Core concept. A CORS middleware placed after authentication will silently fail for browser clients. An exception handler placed anywhere but first means exceptions from earlier middleware are uncaught. Getting the order right β and documenting why β is one of the most important things you can do in Program.cs.
Common Pitfalls
1CORS Preflight Failing in Production
A React SPA makes a POST to your API with a custom Authorization header. It works in dev but breaks in production with 'No Access-Control-Allow-Origin header'. The browser's preflight OPTIONS request returns 401.
UseCors() was added AFTER UseAuthentication() in Program.cs. Preflight OPTIONS requests don't carry credentials, so UseAuthentication marked them as unauthenticated. UseAuthorization then rejected them with 401 before the CORS middleware ever ran.
Move app.UseCors() before app.UseAuthentication(). CORS middleware must see the request before authentication so it can respond to OPTIONS preflights without requiring credentials. This is documented but easy to get wrong when refactoring startup code.
Takeaway: Middleware order bugs are silent in development because you often test from the same origin. Always verify CORS headers with a cross-origin request in staging, and keep a comment in Program.cs documenting WHY the order is what it is.
2Exception Handler Swallowing Stack Traces
You add app.UseExceptionHandler('/error') to return JSON errors. Suddenly your logs show no stack traces for 500 errors β just 'An error occurred while processing your request.' Debugging takes hours.
UseExceptionHandler re-executes the pipeline to /error, clearing the original exception from the response. If you don't read HttpContext.Features.Get<IExceptionHandlerFeature>()?.Error in your error controller, you lose the exception entirely.
In your /error controller, always read the exception feature: var ex = HttpContext.Features.Get<IExceptionHandlerFeature>()?.Error. Log it with full stack trace before returning the sanitized response. In development, use app.UseDeveloperExceptionPage() which shows the full trace.
Takeaway: UseExceptionHandler is a pipeline reset β it loses exception context unless you explicitly retrieve it from the features collection. Always wire up proper logging inside your error handler, not just in the exception middleware itself.
3Custom Middleware Breaking Streaming Responses
A response logging middleware wraps every request and logs the response body size. SSE (Server-Sent Events) endpoints start timing out and clients disconnect β the events stop flowing after 30 seconds.
The logging middleware buffered the response body to read its size, which called context.Response.Body.Seek(0, SeekOrigin.Begin). This broke the streaming nature of SSE responses. The middleware was effectively buffering the entire stream before forwarding.
Check for streaming responses before buffering: if (context.Response.ContentType?.Contains('text/event-stream') == true) { await next(context); return; }. Better: use a memory stream as a proxy only for non-streaming responses, detected by ContentLength or Content-Type.
Takeaway: Middleware that reads or wraps Response.Body must be carefully tested with streaming endpoints (SSE, gRPC, large file downloads). A middleware that's harmless for JSON APIs can completely break streaming β and the failure mode is subtle, not an immediate error.