Every HTTP request hitting an ASP.NET Core application passes through a chain of components before reaching your API controllers or endpoint handlers. This mechanism is the ASP.NET Core middleware pipeline. Understanding how this request flow operates keeps your applications secure, performant, and free of subtle ordering bugs that baffle developers.
Middleware consists of small software units assembled into an application pipeline. Each component handles incoming HTTP requests, performs an action, and either passes control to the next component or stops the chain entirely. Think of it as a series of checkpoints where requests are inspected, modified, logged, or rejected.
How the Request Pipeline Works
The request pipeline operates in a bidirectional manner. When a client sends an HTTP request, it enters the application and flows forward through each registered middleware component. Once the request reaches an endpoint or a terminal component, a response is generated. That response then travels backward through the exact same middleware components in reverse order.
Client Request ──► [ Middleware 1 ──► Middleware 2 ──► Endpoint ]
Client Response ◄── [ Middleware 1 ◄── Middleware 2 ◄── Endpoint ]
Because of this design, a single piece of middleware can inspect the incoming request before calling next(), and then inspect or modify the outgoing response after next() finishes. This bidirectional flow is what makes components like performance timers and loggers so efficient; they can record the start time, await the rest of the pipeline, and calculate total request duration on the way back out.
Core Registration Methods: Use, Run, and Map
Configuring this pipeline happens inside your application setup using extension methods on WebApplication. Knowing when to use each method prevents common architectural mistakes.
The Use Method
The app.Use() method adds a middleware component to the chain and allows you to call the next delegate to pass the request forward. If you don't invoke next, the pipeline short-circuits.
app.Use(async (context, next) =>
{
// Code running on the request path
var watch = System.Diagnostics.Stopwatch.StartNew();
await next(context); // Passes control downstream
// Code running on the response path
watch.Stop();
context.Response.Headers.Append("X-Response-Time", $"{watch.ElapsedMilliseconds}ms");
});
The Run Method
The app.Run() method adds a terminal middleware. A run delegate does not receive a next parameter, meaning it cannot pass the request to any subsequent components. Use this when you want to handle a request directly and terminate the pipeline immediately.
app.Run(async context =>
{
context.Response.StatusCode = StatusCodes.Status200OK;
await context.Response.WriteAsync("Hello from terminal middleware!");
});
The Map and MapWhen Methods
When you want to branch your pipeline based on the request path or specific conditions, use Map or MapWhen.
app.Map("/admin", adminApp => { ... }): Forks the pipeline for any request starting with/admin.app.MapWhen(context => context.Request.Query.ContainsKey("debug"), debugApp => { ... }): Forks the pipeline based on a custom predicate.
Why Middleware Order Matters
ASP.NET Core evaluates middleware strictly in the order they are registered in your setup code. Get the order wrong, and your application will either misbehave or fail silently.
For example, if you place your authentication middleware after your endpoint routing, incoming requests won't be authenticated when routing decisions are made. Similarly, global exception handling must sit at the absolute top of the pipeline. If an exception occurs deep inside a controller, it bubbles backward and can only be caught by middleware registered before the failure point.
A standard, production-ready pipeline order looks like this:
- Exception Handling: Catches unhandled exceptions thrown anywhere downstream.
- HTTPS Redirection: Forces insecure HTTP requests over to HTTPS.
- Static Files: Serves images, CSS, and JS files, short-circuiting for static assets.
- Routing: Analyzes the URL to determine which endpoint matches.
- CORS: Enforces Cross-Origin Resource Sharing policies.
- Authentication: Identifies who the user is.
- Authorization: Validates what the user is allowed to do.
- Endpoints: Executes the matched controller action or minimal API handler.
Writing Custom Middleware Classes
While inline lambdas using app.Use() work well for small tasks, production apps require clean, testable custom middleware classes. A standard convention-based middleware requires a constructor accepting a RequestDelegate and an InvokeAsync method.
public class ApiKeyMiddleware
{
private readonly RequestDelegate _next;
private const string ApiKeyHeaderName = "X-Api-Key";
private const string ValidApiKey = "secret-key-123";
public ApiKeyMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
if (!context.Request.Headers.TryGetValue(ApiKeyHeaderName, out var extractedApiKey) ||
extractedApiKey != ValidApiKey)
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsync("API Key missing or invalid.");
return; // Short-circuit pipeline
}
await _next(context);
}
}
To make this clean to register in your application setup, wrap it in an extension method following standard Microsoft .NET guidelines:
public static class ApiKeyMiddlewareExtensions
{
public static IApplicationBuilder UseApiKeyValidation(this IApplicationBuilder builder)
{
return builder.UseMiddleware<ApiKeyMiddleware>();
}
}
Then, add it to your configuration pipeline right where authentication checks belong:
app.UseApiKeyValidation();
Common Mistakes to Avoid
Building and maintaining robust pipelines requires avoiding a few frequent architectural traps:
- Bloating individual components: Keep each middleware focused on a single responsibility. Do not mix validation, logging, and database updates inside a single massive component.
- Forgetting to call next: If you write custom middleware using
app.Use()but forget to invokeawait next(context), the request freezes and hangs indefinitely unless you explicitly write a response and return. - Blocking asynchronous code: Never use
.Resultor.Wait()inside middleware. Always useasync/awaitall the way through to prevent thread pool starvation under high loads. - Ignoring exception placement: Placing your exception handler near the bottom of the file means errors thrown by middleware above it will bypass your global error handler.
Frequently Asked Questions
What is the difference between Use and Run in ASP.NET Core?
The Use method allows you to pass the request along to subsequent components by invoking the next delegate. The Run method defines a terminal delegate that handles the request and ends the pipeline without calling any further components.
Can middleware modify the response body?
Yes, but with caveats. Because the response stream is forward-only by default, modifying the response body after downstream components have written to it requires buffering the stream into memory or using a temporary stream wrapper so headers and content lengths can be adjusted safely.
How do I pass data from middleware to a controller?
You can store items in the HttpContext.Items dictionary. Anything placed into context.Items during the request leg of the pipeline remains accessible downstream inside your controllers or endpoint handlers for the duration of that single request.
Why is my custom middleware executing after my controller?
This happens when you place code after the await next(context) statement. Code placed before next() runs on the incoming request, while code placed after next() runs on the outgoing response on its way back up the pipeline. Check your placement and ensure you aren't trying to modify request headers after the response has already started streaming.