software

Asp.net Core Middleware Pipeline: How Requests Flow

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 pip

Asp.net Core Middleware Pipeline: How Requests Flow
In this story

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:

  1. Exception Handling: Catches unhandled exceptions thrown anywhere downstream.
  2. HTTPS Redirection: Forces insecure HTTP requests over to HTTPS.
  3. Static Files: Serves images, CSS, and JS files, short-circuiting for static assets.
  4. Routing: Analyzes the URL to determine which endpoint matches.
  5. CORS: Enforces Cross-Origin Resource Sharing policies.
  6. Authentication: Identifies who the user is.
  7. Authorization: Validates what the user is allowed to do.
  8. 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 invoke await next(context), the request freezes and hangs indefinitely unless you explicitly write a response and return.
  • Blocking asynchronous code: Never use .Result or .Wait() inside middleware. Always use async/await all 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.

IC

Written by

Isabella Carter

As a veteran correspondent, Isabella Carter has reported from across the globe, bringing firsthand perspectives to international stories and local issues.

More from software