Start Debugging

Endpoint filters vs middleware in ASP.NET Core 11: which should you use?

A decision guide for ASP.NET Core 11: middleware runs for every request before your handler binds, endpoint filters run only for the matched endpoint after binding and can see the typed arguments. Includes a feature matrix, when-to-pick-each scenarios, the ordering rules, and the gotchas that force the choice.

Reach for middleware when the logic must run for every request, before or independent of which endpoint matched: exception handling, CORS, authentication, response compression, static files, forwarded headers. Reach for an endpoint filter when the logic needs the bound handler arguments, or should apply to only some endpoints: input validation, argument normalization, per-endpoint auditing. The single sharpest test: if your code needs the typed model the handler is about to receive, it wants a filter, because a filter runs after model binding and can read context.GetArgument<T>(index). If it needs to run whether or not a route matched, it wants middleware, because middleware runs before routing resolves an endpoint. Everything below is the detail behind that call. This targets .NET 11 (Preview 6 at the time of writing, GA in November 2026) with Microsoft.NET.Sdk.Web and C# 14, but both features have been stable since ASP.NET Core 7, so every example here runs unchanged on .NET 8, 9, and 10.

The feature matrix

This is the table you came for. Read it top to bottom and the decision usually makes itself.

FeatureEndpoint filterMiddleware
Runs foronly the matched endpointevery request on that pipeline branch
Position relative to routingafter routing and model bindingbefore, at, or after routing (placement)
Sees handler argumentsyes, typed via GetArgument<T>(index)no, only the raw HttpContext
Can mutate the bound argumentsyes, context.Arguments is mutableno, binding has not happened yet
Short-circuit mechanismreturn an IResult instead of nextdo not call next(context)
Scope controlper endpoint or per MapGroupper app, or per branch via Map/UseWhen
Registration.AddEndpointFilter(...)app.Use(...) / app.UseMiddleware<T>()
Return typeValueTask<object?>Task
Runs when no endpoint matchedneveryes, if placed before endpoint execution
Reusable on MVC controllersyes, on controller endpoints tooyes, pipeline-wide

The rows that actually decide the choice are the first three. Middleware sits in the request pipeline and every request flowing through that segment executes it, even a request that will 404 because no endpoint matched. An endpoint filter is bound to a specific route handler and only runs when that handler is selected, which happens after UseRouting has matched the request and after the framework has bound the route values, query string, and request body into the handler’s parameters. That timing difference is the whole story.

What middleware sees, and when

Middleware is a chain of components, each of which receives the HttpContext and a next delegate. You register them in Program.cs in order, and the order is the behavior: requests flow top to bottom, responses flow back bottom to top.

// .NET 11, C# 14 -- Program.cs
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.Use(async (context, next) =>
{
    // Runs for EVERY request, including ones that will 404.
    var sw = System.Diagnostics.Stopwatch.StartNew();
    await next(context);
    sw.Stop();
    app.Logger.LogInformation(
        "{Method} {Path} -> {Status} in {Elapsed}ms",
        context.Request.Method, context.Request.Path,
        context.Response.StatusCode, sw.ElapsedMilliseconds);
});

app.MapGet("/hello/{name}", (string name) => $"Hi {name}");

app.Run();

That timing middleware measures the whole request, including routing and any 404. It only has access to context.Request.Path as a string. It cannot see that name bound to "world", because at the point the outer middleware runs, binding has not happened yet. Middleware operates one level below your handler’s type system.

Placement relative to UseRouting matters more than people expect. In the modern minimal hosting model, WebApplication inserts routing automatically, but you can call app.UseRouting() explicitly to control where the split happens. Middleware registered before routing runs before an endpoint is even selected. Middleware registered after UseRouting can read the selected endpoint’s metadata through context.GetEndpoint(), which is how UseAuthorization knows which policy to enforce. This is why the canonical order is UseRouting, then UseAuthentication, then UseAuthorization, then endpoint execution: authorization needs the endpoint metadata that routing produced.

What an endpoint filter sees, and when

An endpoint filter wraps the invocation of a single route handler. It runs after routing and after binding, so it has the one thing middleware cannot get: the actual, typed arguments your handler is about to receive.

// .NET 11, C# 14
app.MapPost("/orders", (Order order) => Results.Created($"/orders/{order.Id}", order))
    .AddEndpointFilter(async (context, next) =>
    {
        // The Order is already bound. Middleware could never see this.
        var order = context.GetArgument<Order>(0);
        if (order.Quantity < 1)
        {
            return Results.Problem("Quantity must be at least 1.");
        }
        return await next(context);
    });

The filter’s return type is ValueTask<object?>. Returning any IResult (like Results.Problem) short-circuits and writes that result to the response without ever calling the handler. Returning await next(context) runs the handler and passes its result back up the chain, so a filter can also transform the response on the way out. Because the filter sees the bound Order, validation lives naturally here. A middleware component trying to do the same job would have to re-read and re-deserialize the request body itself, duplicating work the framework already did. The full mechanics of AddEndpointFilter, the class-based IEndpointFilter form, and filter ordering are covered in how to add an endpoint filter to a minimal API; this post is about when to choose it over middleware in the first place.

When to pick middleware

When to pick an endpoint filter

The one place performance actually enters the decision

It is tempting to reach for a filter “because middleware runs for everything and that is wasteful.” Resist framing it as a throughput contest. Both features are thin: a filter is a ValueTask<object?>-returning delegate, and a middleware component is a Task-returning delegate, and the per-invocation overhead of either is negligible next to any real handler that touches a database or serializes JSON. The meaningful difference is not cost per call, it is how many calls happen. A middleware component placed early in the pipeline executes on every request, so expensive work there (a database lookup, a large allocation) is paid by every 404 and every health-check ping. The same work in an endpoint filter runs only when that endpoint is selected. So the performance rule is not “filters are faster,” it is “scope the work to where it is needed.” If a cross-cutting concern genuinely applies to every route, middleware is the correct and not-slower home for it. If it applies to a handful of endpoints, a filter avoids running it on the thousands of requests that will never touch those endpoints. That is a scoping decision dressed up as a performance one, and it is the honest version of the claim.

The gotchas that pick for you

A few hard constraints override preference entirely.

A filter cannot run before routing, ever. If your requirement is “reject the request before the router sees it” or “rewrite the URL,” a filter is physically incapable of it, because it lives inside endpoint execution, which is downstream of routing. This forces middleware.

Middleware cannot see the bound model without redoing the work. If your requirement is “validate the deserialized request body,” middleware would have to buffer and deserialize the body itself, then the framework deserializes it again for the handler. That double-bind is a strong signal you wanted a filter. This forces a filter.

Exceptions escape a filter’s scope. A filter only wraps its endpoint, so it cannot be your app-wide safety net. If you put your only exception handling in a filter, an exception thrown in another middleware, or during routing, sails past it and hits the default 500 handler. Global error handling forces middleware.

Ordering models differ, and mixing them confuses people. Middleware nests by registration order in Program.cs. Filters nest by the order you chain .AddEndpointFilter calls: first-in runs its pre-next code first and its post-next code last. When you stack both, the entire filter chain for an endpoint runs inside the innermost point of the middleware pipeline, after UseRouting, UseAuthentication, and UseAuthorization have all executed. So authorization always runs before any endpoint filter, which is usually what you want, but it means a filter is the wrong place to implement an authentication scheme. Authentication forces middleware.

Terminal behavior is opposite. A middleware component that does not call next short-circuits by simply not continuing. A filter short-circuits by returning an IResult. If you write a filter and forget to return something on the short-circuit path, you get a compile error or a null result rather than a silently swallowed request, which is a small but real ergonomic win for filters.

The recommendation, restated

Default to this: cross-cutting concerns that must run for every request, or before routing, are middleware. Concerns that need the typed handler arguments, or that apply to a subset of endpoints, are endpoint filters. Authentication, CORS, exception handling, compression, and static files are middleware and always will be. Validation, argument normalization, per-endpoint auditing, and result-shaping are endpoint filters. The gray-zone case is per-endpoint authorization logic: if it only needs claims from HttpContext.User, either works, but prefer a filter so the policy lives next to the endpoint it guards; if it needs the bound arguments to make the decision (row-level access checks on a bound entity id), it must be a filter. When you genuinely cannot decide, ask the one question that resolves almost every case: does this code need to see the arguments my handler will receive? Yes means filter. No, and it must run regardless of the route, means middleware.

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back