ASP.NET Core middleware is the chain of components that every HTTP request passes through on the way in and every response passes through on the way out. Exception handling, HTTPS redirection, authentication, CORS, compression and routing are all middleware, and the order you register them in decides whether your app is secure, fast and correct. This guide explains how the RequestDelegate pipeline is built, how Use, Run, Map and UseWhen differ, the correct order for built-in middleware, how to write convention-based and IMiddleware components, and how to handle exceptions, avoid response buffering traps and test your middleware.

What Is ASP.NET Core Middleware?#

A middleware component is a piece of code that receives the HttpContext and a reference to the next component. It can do work before calling the next component, do work after it returns, or not call it at all. That last option, called short-circuiting, is how static files, health checks and authorization failures end a request early.

The model replaces the HttpModule and HttpHandler system of classic ASP.NET with something simpler: everything is a function. A RequestDelegate is just a delegate that takes an HttpContext and returns a Task. Middleware is a function that takes the next RequestDelegate and returns a new one. Because the pipeline is plain code composed in Program.cs, you can read it top to bottom and know exactly what runs for every request. The ASP.NET Core architecture guide shows where the pipeline sits between the server and your endpoints.

How the Middleware Pipeline Works#

At startup, every Use, Run and UseMiddleware call adds a Func<RequestDelegate, RequestDelegate> to a list. When the app is built, the builder wraps these functions from last to first, so the first registered component becomes the outermost layer:

C#
// Simplified version of what IApplicationBuilder.Build() does at startup.
RequestDelegate pipeline = context =>
{
    context.Response.StatusCode = StatusCodes.Status404NotFound; // Nothing handled it
    return Task.CompletedTask;
};

for (var i = components.Count - 1; i >= 0; i--)
{
    pipeline = components[i](pipeline); // Each component receives the next one
}

// Per request, the server just calls: await pipeline(httpContext);

Three consequences follow from this design:

  • The pipeline is built once. Composition costs nothing per request, and convention-based middleware instances live for the lifetime of the app.
  • Order is everything. Code before await next(context) runs in registration order, and code after it runs in reverse order, like nested function calls.
  • Falling off the end means 404. If no component writes a response, the terminal delegate sets 404. It throws instead if routing selected an endpoint that never executed, which usually means UseEndpoints is missing.

Getting Started: Use, Run, Map, MapWhen and UseWhen#

Five extension methods build every pipeline. Use adds a pass-through component, Run adds a terminal one, Map and MapWhen create branches that never rejoin, and UseWhen creates a conditional branch that does rejoin the main pipeline:

C#
var app = WebApplication.Create(args);

// Use: do work, then call next (or skip it to short-circuit).
app.Use(async (context, next) =>
{
    context.Response.Headers["X-Content-Type-Options"] = "nosniff";
    await next(context);
});

// UseWhen: a conditional branch that rejoins the main pipeline afterward.
app.UseWhen(ctx => ctx.Request.Path.StartsWithSegments("/api"), api =>
    api.Use(async (context, next) =>
    {
        context.Response.Headers.CacheControl = "no-store";
        await next(context);
    }));

// Map: branch on a path prefix. The prefix moves from Path to PathBase.
app.Map("/ping", branch => branch.Run(ctx => ctx.Response.WriteAsync("pong")));

// MapWhen: branch on any predicate. The branch never rejoins.
app.MapWhen(ctx => ctx.Request.Query.ContainsKey("legacy"),
    branch => branch.Run(ctx => ctx.Response.WriteAsync("Legacy mode is retired.")));

// Run with a delegate: terminal middleware. Nothing registered after it executes.
app.Run(ctx => ctx.Response.WriteAsync("Hello from the end of the pipeline"));

app.Run(); // WebApplication.Run(): starts the server

Prefer the Use overload whose next parameter is a RequestDelegate that you call with next(context). The older overload with a parameterless Func<Task> costs two extra allocations per request. For most branching needs, endpoint routing is a better tool than Map, because endpoints participate in authorization, CORS and OpenAPI.

Ordering ASP.NET Core Middleware Correctly#

Middleware order bugs are silent. The app compiles and runs, but CORS headers go missing, authenticated content gets cached for anonymous users, or HTTPS redirects loop forever. The following order works for most API and server-rendered apps:

C#
var app = builder.Build();

app.UseForwardedHeaders();         // Fix scheme and client IP before anything reads them
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler();     // Catches exceptions from everything registered after it
    app.UseHsts();
}
app.UseHttpsRedirection();
app.UseResponseCompression();      // Wraps the response body of everything below
app.UseStaticFiles();              // Serves files and short-circuits early
app.UseRouting();                  // Selects the endpoint; policies below can read it
app.UseCors();                     // Before authentication, authorization and output caching
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();              // After routing for endpoint-specific policies
app.UseRequestTimeouts();          // After routing when routing is explicit
app.UseOutputCache();              // After CORS and auth, or anonymous users get cached pages
app.UseAntiforgery();              // After authentication and authorization

app.MapControllers();
app.MapHealthChecks("/healthz");
app.Run();

The rules behind that list come from the official middleware documentation:

  • Exception handling goes first, so it can catch failures from everything after it. In Development, WebApplication adds the developer exception page automatically.
  • Forwarded headers go before HSTS, HTTPS redirection and authentication, because those components depend on the original scheme and client address.
  • Routing comes before anything that reads endpoint metadata. Authorization, CORS policies, [EnableRateLimiting], request timeouts and output cache policies are all attached to endpoints.
  • CORS comes before authentication and authorization, so that preflight requests aren't rejected, and before response caching and output caching, so cached responses still carry CORS headers.
  • Output caching comes after authentication and authorization. Otherwise it can serve a response cached for an authorized user to an unauthorized one.

Caching and compression can be ordered either way. Registering response caching before compression caches the compressed output, which saves CPU but can store several encodings of the same resource. .NET 11 adds Zstandard to the response compression middleware and always emits Vary: Accept-Encoding when compression is enabled.

Writing Custom Middleware#

When inline lambdas grow, move them into a class. The convention-based pattern needs a public constructor that accepts a RequestDelegate and a public Invoke or InvokeAsync method whose first parameter is HttpContext. Because the instance is created once, inject singletons through the constructor and scoped services through InvokeAsync parameters:

C#
public interface IRequestContext
{
    string? CorrelationId { get; set; }
}

public sealed class RequestContext : IRequestContext
{
    public string? CorrelationId { get; set; }
}

public sealed class CorrelationIdMiddleware(
    RequestDelegate next, ILogger<CorrelationIdMiddleware> logger)
{
    private const string HeaderName = "X-Correlation-Id";

    // Scoped services belong in InvokeAsync, never in the constructor.
    public async Task InvokeAsync(HttpContext context, IRequestContext requestContext)
    {
        var correlationId = context.Request.Headers[HeaderName].ToString() is
            { Length: > 0 and <= 64 } incoming ? incoming : Guid.NewGuid().ToString("N");

        requestContext.CorrelationId = correlationId;
        context.Response.OnStarting(() =>
        {
            context.Response.Headers[HeaderName] = correlationId;
            return Task.CompletedTask;
        });

        using (logger.BeginScope(
            new Dictionary<string, object> { ["CorrelationId"] = correlationId }))
        {
            await next(context);
        }
    }
}

public static class CorrelationIdMiddlewareExtensions
{
    public static IApplicationBuilder UseCorrelationId(this IApplicationBuilder app) =>
        app.UseMiddleware<CorrelationIdMiddleware>();
}

Register the scoped dependency with builder.Services.AddScoped<IRequestContext, RequestContext>() and add the component with app.UseCorrelationId(). The length check matters, because echoing unvalidated client input into logs and headers invites log injection. If you already use OpenTelemetry, the W3C traceparent header may make a custom correlation ID unnecessary. See the OpenTelemetry guide.

Factory-Based Middleware with IMiddleware#

IMiddleware offers a strongly typed alternative. The class implements InvokeAsync(HttpContext, RequestDelegate), you register it in dependency injection, and IMiddlewareFactory resolves a new instance for each request. Scoped services such as a DbContext can then go straight into the constructor:

C#
public sealed class TenantResolutionMiddleware(TenantDbContext db) : IMiddleware
{
    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        var host = context.Request.Host.Host;
        var tenant = await db.Tenants.AsNoTracking()
            .SingleOrDefaultAsync(t => t.Host == host, context.RequestAborted);

        if (tenant is null)
        {
            context.Response.StatusCode = StatusCodes.Status404NotFound;
            return; // Short-circuit: unknown tenant
        }

        context.Items[typeof(Tenant)] = tenant;
        await next(context);
    }
}

// IMiddleware types must be registered, as scoped or transient.
builder.Services.AddScoped<TenantResolutionMiddleware>();
app.UseMiddleware<TenantResolutionMiddleware>();
AspectConvention-based middlewareIMiddleware
ActivationOnce, when the pipeline is builtPer request, through IMiddlewareFactory
RegistrationNone; just UseMiddleware<T>()Register in DI, then UseMiddleware<T>()
Scoped dependenciesInvokeAsync parametersConstructor
Extra UseMiddleware argumentsSupportedThrows NotSupportedException
ContractMethod shape found by conventionCompile-time interface
Per-request costLowestOne DI resolution per request

Driving Middleware with Endpoint Metadata#

Path checks such as StartsWithSegments("/admin") break as soon as routes change. A sturdier pattern attaches metadata to endpoints and lets middleware placed after UseRouting react to it. This is exactly how authorization, CORS and rate limiting work internally:

C#
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public sealed class AuditAttribute : Attribute
{
}

app.UseRouting();

app.Use(async (context, next) =>
{
    // The endpoint and its metadata are available because this runs after UseRouting.
    if (context.GetEndpoint()?.Metadata.GetMetadata<AuditAttribute>() is not null)
    {
        var audit = context.RequestServices.GetRequiredService<IAuditLog>();
        await audit.RecordAsync(context.User, context.Request.Path, context.RequestAborted);
    }

    await next(context);
});

app.MapDelete("/api/customers/{id:guid}", DeleteCustomer)
   .WithMetadata(new AuditAttribute()); // Or put [Audit] on the handler method

The opt-in marker keeps the policy next to the endpoint it protects. Controllers work the same way, because attributes on classes and actions become endpoint metadata automatically.

Short-Circuiting the Pipeline#

A component short-circuits by writing a response and returning without calling next. Middleware registered earlier still runs its code after next, so logging and timing wrappers see short-circuited requests too. Two rules keep short-circuiting safe:

  • Don't call next after writing the body. Once the response has started, changing headers or the status code throws, and writing more content can corrupt the response or violate Content-Length. Check HttpResponse.HasStarted if you're unsure.
  • Short-circuit with routing when you can. Since .NET 8, ShortCircuit() runs a matched endpoint immediately after routing and skips the rest of the middleware, and MapShortCircuit(404, "robots.txt", "favicon.ico") answers noisy bot probes cheaply. .NET 11 adds a [ShortCircuit] attribute for handlers and controllers. Short-circuited endpoints can't carry authorization or CORS metadata.

Exception Handling Middleware in ASP.NET Core#

UseExceptionHandler catches exceptions from everything registered after it, logs them, clears the response and produces an error response. That response can come from a re-executed error path, an inline handler, or, when you call AddProblemDetails(), an RFC 9457 problem details body. Registered IExceptionHandler services run first, in registration order, and the first one that returns true owns the response:

C#
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<BusinessRuleExceptionHandler>();

var app = builder.Build();

app.UseExceptionHandler(new ExceptionHandlerOptions
{
    // .NET 9 and later: choose the status code for the default response.
    StatusCodeSelector = exception => exception switch
    {
        BadHttpRequestException badRequest => badRequest.StatusCode,
        TimeoutException => StatusCodes.Status504GatewayTimeout,
        _ => StatusCodes.Status500InternalServerError
    },
    // .NET 10: exceptions handled by an IExceptionHandler aren't logged by default.
    // Returning false keeps diagnostics for everything except expected rule failures.
    SuppressDiagnosticsCallback = context => context.Exception is BusinessRuleException
});
app.UseStatusCodePages(); // Gives empty 4xx and 5xx responses a problem details body

public sealed class BusinessRuleExceptionHandler(IProblemDetailsService problemDetails)
    : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
    {
        if (exception is not BusinessRuleException rule) return false; // Try the next handler

        httpContext.Response.StatusCode = StatusCodes.Status422UnprocessableEntity;
        return await problemDetails.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = httpContext,
            Exception = exception,
            ProblemDetails = new() { Title = "Business rule violated", Detail = rule.Message }
        });
    }
}

Know the edge cases. If the response has already started when an exception occurs, the middleware can't change anything, so it rethrows and the connection is aborted. When the client disconnects and the exception is an OperationCanceledException or IOException, the middleware logs a request-aborted message and sets status 499 instead of treating it as a server error. Calling UseExceptionHandler() with no arguments requires AddProblemDetails() or a configured handler, and otherwise fails at startup.

Response Buffering Pitfalls#

Kestrel streams responses. Headers go out with the first flush, and the body follows in chunks. Middleware that needs to see or change the whole body has to break that model, and the cost is easy to underestimate:

  • Swapping Response.Body for a MemoryStream holds entire responses in memory, delays time-to-first-byte and breaks streaming endpoints such as Server-Sent Events, SignalR and gRPC. If you must do it, limit it to known small JSON endpoints, always restore the original stream in a finally block, and fix Content-Length.
  • Adding headers after next returns fails if the body has already been flushed. Register a Response.OnStarting callback instead, which runs just before the headers are sent.
  • Reading the request body twice requires Request.EnableBuffering(), which keeps up to 30 KB in memory and spills larger bodies to a temporary file. Rewind with Position = 0 before calling next.
  • Hand-written body logging is almost always worse than the built-in HTTP logging middleware, which caps logged request and response bodies at 32 KB each by default.
C#
app.Use(async (context, next) =>
{
    var start = Stopwatch.GetTimestamp();

    // Runs just before headers are sent, even for streamed responses.
    context.Response.OnStarting(() =>
    {
        var elapsed = Stopwatch.GetElapsedTime(start).TotalMilliseconds;
        context.Response.Headers["Server-Timing"] = $"app;dur={elapsed:F1}";
        return Task.CompletedTask;
    });

    await next(context);
});

app.UseWhen(ctx => ctx.Request.Path.StartsWithSegments("/webhooks"), hooks =>
    hooks.Use(async (context, next) =>
    {
        context.Request.EnableBuffering(); // Memory up to 30 KB, then a temp file
        using var reader = new StreamReader(context.Request.Body, leaveOpen: true);
        var payload = await reader.ReadToEndAsync(context.RequestAborted);
        context.Request.Body.Position = 0; // Rewind so model binding can read it again

        var verifier = context.RequestServices.GetRequiredService<IWebhookSignatureVerifier>();
        if (!verifier.IsValid(payload, context.Request.Headers["X-Signature"]))
        {
            context.Response.StatusCode = StatusCodes.Status401Unauthorized;
            return;
        }

        await next(context);
    }));

Testing ASP.NET Core Middleware#

Test middleware at two levels. A unit test constructs the component with a stub next delegate and a DefaultHttpContext, which is fast and needs no server. However, DefaultHttpContext never fires OnStarting callbacks, so assertions on response headers belong in an in-memory TestServer test from the Microsoft.AspNetCore.TestHost package:

C#
public class CorrelationIdMiddlewareTests
{
    [Fact]
    public async Task Generates_id_when_header_is_missing()
    {
        var context = new DefaultHttpContext();
        var requestContext = new RequestContext();
        var middleware = new CorrelationIdMiddleware(
            next: _ => Task.CompletedTask,
            logger: NullLogger<CorrelationIdMiddleware>.Instance);

        await middleware.InvokeAsync(context, requestContext);

        Assert.False(string.IsNullOrEmpty(requestContext.CorrelationId));
    }

    [Fact]
    public async Task Echoes_client_id_in_response_header()
    {
        using var host = await new HostBuilder()
            .ConfigureWebHost(web => web
                .UseTestServer()
                .ConfigureServices(services =>
                    services.AddLogging().AddScoped<IRequestContext, RequestContext>())
                .Configure(app =>
                {
                    app.UseCorrelationId();
                    app.Run(ctx => ctx.Response.WriteAsync("ok"));
                }))
            .StartAsync();

        var request = new HttpRequestMessage(HttpMethod.Get, "/");
        request.Headers.Add("X-Correlation-Id", "abc123");

        var response = await host.GetTestClient().SendAsync(request);

        Assert.Equal("abc123", response.Headers.GetValues("X-Correlation-Id").Single());
    }
}

For ordering bugs, which only appear when components interact, test the real pipeline with WebApplicationFactory<Program> as described in the integration testing guide.

Middleware Changes in .NET 10 and .NET 11#

The pipeline model has been stable since ASP.NET Core 3.0, but recent releases changed several defaults that affect middleware behavior:

  • .NET 10 (LTS): exceptions handled by an IExceptionHandler no longer produce error logs or the error.type metric tag by default, controlled by SuppressDiagnosticsCallback. The memory pools behind Kestrel, IIS and HTTP.sys now release idle memory automatically.
  • .NET 11 (RC1 shipped September 8, 2026, with a go-live license): a [ShortCircuit] attribute complements ShortCircuit(). Response compression gains Zstandard and always sends Vary: Accept-Encoding. The HTTP server activity natively carries OpenTelemetry semantic-convention attributes.
  • Cross-origin protection in .NET 11: apps built with WebApplication.CreateBuilder get a header-based cross-site request forgery check that uses Sec-Fetch-Site and Origin. Since Preview 7 it validates only endpoints that already require antiforgery, such as form posts, so endpoints that worked on .NET 10 keep working.

Because .NET 8 and .NET 9 both reach end of support on November 10, 2026, the .NET 10 behavior is the baseline to design for today.

Best Practices#

  • Keep Program.cs readable. Wrap each custom component in a UseXxx() extension method so the pipeline reads as a list of concerns.
  • Make order explicit when it matters. Call UseRouting, UseAuthentication and UseAuthorization yourself instead of relying on the automatic placement.
  • Stay asynchronous and allocation-aware. Middleware runs on every request, so avoid blocking calls, LINQ-heavy header parsing and per-request allocations in hot paths.
  • Respect cancellation. Pass context.RequestAborted to I/O calls so abandoned requests stop consuming resources.
  • Prefer endpoint metadata over path checks. Attach a marker attribute or policy to endpoints and read it from middleware placed after routing.
  • Use built-in middleware first. Rate limiting, output caching, request timeouts, HTTP logging and response compression are all well tested and well documented.

Common Pitfalls#

  • Resolving scoped services in a convention-based constructor. The instance lives for the whole app, so the scoped service becomes a captive dependency. Development scope validation catches this at startup.
  • UseCors after UseAuthorization, which rejects preflight requests or drops CORS headers.
  • Output caching before authentication, which can leak personalized responses.
  • Forgetting that Map changes paths. Inside a Map("/api") branch, Request.Path no longer includes /api, which moves to PathBase.
  • Throwing after the response starts. The exception handler can't recover, and the client sees a truncated response.
  • Registering IMiddleware without adding it to DI, which fails at runtime when the factory can't resolve it.

Middleware vs Endpoint Filters vs MVC Filters#

MechanismRuns forKnows the endpointSees bound argumentsTypical use
Middleware before routingEvery requestNoNoForwarded headers, HTTPS, static files, compression
Middleware after routingEvery request, with endpoint metadataYesNoAuthentication, authorization, CORS, rate limiting, caching
Endpoint filtersMinimal API endpoints (and controllers via AddEndpointFilter)YesYesArgument checks, validation, result shaping
MVC filtersControllers and Razor PagesYesYes, after model bindingAction-level policies and ModelState handling

Use middleware for concerns that apply to HTTP in general, and filters for concerns that depend on a handler's parameters or results. The Minimal APIs guide covers endpoint filters in depth.

Frequently Asked Questions#

What is the difference between Use and Run in ASP.NET Core?#

Use adds middleware that receives a next delegate and can pass the request along. Run adds terminal middleware with no next, so nothing registered after it executes. Note that app.Run() with no arguments is a different method on WebApplication that starts the server.

Should I write convention-based middleware or implement IMiddleware?#

Use convention-based middleware by default. It is created once and is the cheapest option. Choose IMiddleware when constructor injection of scoped services, such as a DbContext, makes the code clearer and a per-request DI resolution is acceptable.

Why doesn't my middleware see the selected endpoint?#

It runs before UseRouting. HttpContext.GetEndpoint() returns null until routing has matched the request. Move your middleware after app.UseRouting(), or call UseRouting explicitly before it when you rely on WebApplication's automatic placement.

How do I add a response header from middleware?#

Set it before calling next if the value is known up front. If it depends on the outcome, register context.Response.OnStarting before calling next. Setting headers after next returns fails whenever the body has already been flushed.

Can middleware modify the response body?#

Yes, by replacing Response.Body with your own stream, but it buffers the response and breaks streaming. Limit it to small, known responses, restore the original stream in a finally block, and prefer built-in compression or output transformation where it exists.

Summary#

  • The pipeline is a chain of RequestDelegate functions, built once at startup. Code before next runs in registration order, and code after it runs in reverse.
  • Use Use, Run, Map, MapWhen and UseWhen deliberately, and prefer endpoint routing for URL-based branching.
  • Order matters: exception handling first, forwarded headers early, routing before policy middleware, CORS before auth, and output caching after auth.
  • Write convention-based middleware by default, and use IMiddleware when you need scoped constructor dependencies.
  • Avoid response buffering, set late headers with OnStarting, and test with both DefaultHttpContext and TestServer.

Further Reading#