AI WisdomArchitecture & guides β†—
HT
How Things Work

ASP.NET Core Middleware Pipeline

Every HTTP request passes through a chain of middleware β€” order matters more than you think.

How It Works

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.

1
Pipeline Assembly at Startup

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.

2
Request Flows Inward

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.

3
Response Flows Outward

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.

4
Short-Circuiting the Pipeline

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.

5
app.Use vs app.Run vs app.Map

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

πŸ”—RequestDelegate

The compiled type of a middleware chain: Func<HttpContext, Task>. Each app.Use* call wraps the existing delegate in a new one.

πŸ“IMiddleware

Interface with InvokeAsync(HttpContext, RequestDelegate). Prefer this over the convention-based approach β€” it supports DI constructor injection and is testable.

⚑Short-Circuit

Returning from InvokeAsync without calling next(context). Stops the request from reaching further middleware. The response path still executes for outer middleware.

πŸ—ΊοΈUseRouting / UseEndpoints

UseRouting resolves which endpoint matches the request (populates IEndpointFeature). UseAuthorization needs this to read [Authorize] metadata. Put Authorization AFTER Routing.

🏁Terminal Middleware

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.

πŸ“‹Middleware Order

CORS before Auth, ExceptionHandler first, Routing before Authorization. The order isn't a preference β€” wrong order causes production bugs that are hard to diagnose.

Middleware Order in Program.cs
tsx
1// Program.cs β€” order is everything
2var app = builder.Build();
3
4// MUST be first β€” catches unhandled exceptions from all subsequent middleware
5app.UseExceptionHandler("/error");
6
7// HTTPS redirect before anything else reads the request
8app.UseHttpsRedirection();
9
10// CORS must be before Auth β€” preflight OPTIONS requests
11// must be handled before authorization checks run
12app.UseCors("AllowFrontend");
13
14// Auth before Routing? No. Routing must resolve the endpoint first
15// so [Authorize] metadata is available to UseAuthorization
16app.UseRouting();
17
18app.UseAuthentication(); // Populates HttpContext.User
19app.UseAuthorization(); // Checks [Authorize] on the matched endpoint
20
21// Custom middleware β€” accesses both the resolved route AND the identity
22app.UseMiddleware<RequestLoggingMiddleware>();
23
24app.MapControllers();
25
26// ----------------------------------------------------------------
27// Custom middleware using the IMiddleware interface (DI-friendly)
28public class RequestLoggingMiddleware : IMiddleware
29{
30 private readonly ILogger<RequestLoggingMiddleware> _logger;
31
32 public RequestLoggingMiddleware(ILogger<RequestLoggingMiddleware> logger)
33 => _logger = logger;
34
35 public async Task InvokeAsync(HttpContext context, RequestDelegate next)
36 {
37 var sw = Stopwatch.StartNew();
38 try
39 {
40 await next(context); // Call next middleware in pipeline
41 }
42 finally
43 {
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}
πŸ’‘
Why This Matters

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

⚠UseCors() MUST come before UseAuthentication(). Browser preflight OPTIONS requests don't include credentials, so if authentication runs first it will reject preflights with 401 before CORS headers are ever added.
⚠Calling 'await next(context)' twice will throw 'Cannot write to response after headers have been sent'. This usually happens in error handling code where you forget to return after writing the response.
⚠UseExceptionHandler must be the FIRST middleware. If you put it after UseHttpsRedirection, exceptions thrown during HTTPS redirect are uncaught.
⚠UseRouting() must come BEFORE UseAuthorization(). Authorization needs the endpoint metadata (from [Authorize]) which is only populated after routing resolves the endpoint.
⚠Convention-based middleware (with InvokeAsync but not implementing IMiddleware) is instantiated once as a singleton. Injecting scoped services in the constructor will cause a captured dependency bug β€” inject them via InvokeAsync parameters instead.
Real-World Use Cases

1CORS Preflight Failing in Production

Scenario

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.

Problem

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.

Solution

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

Scenario

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.

Problem

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.

Solution

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

Scenario

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.

Problem

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.

Solution

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.