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:

  1. Do work before calling the next one,
  2. Call the next middleware (or decide not to — short-circuiting),
  3. 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 call next.
  • 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+, WebApplication adds UseRouting and 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

  1. Writing to the response after next() has started it — headers can't be modified once the body starts. Use Response.OnStarting.
  2. Forgetting to call next — the request silently stops.
  3. Reading the request body twice — call context.Request.EnableBuffering() first and reset the stream position.
  4. 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.