TL;DR — The ASP.NET Core middleware pipeline is where your app does its real work, but most developers only scratch the surface. In .NET 9, you've got new tools like
MapStaticAssets, better exception handling, and the same old performance patterns still apply: short-circuit early, order cheap before expensive, and never sync-over-async. Let's look at the patterns actually worth knowing.
Pipeline Basics
Here's the mental model that actually helps: your middleware pipeline is a series of functions. Every incoming HTTP request flows through them in order. Each one can:
- Do some work
- Decide whether to pass the request downstream
- Short-circuit and return immediately
You wire it up with three extension methods:
-
Use— does work, then callsnext()to continue down the pipeline -
Map— forks the pipeline based on a URL path -
Run— terminal. Nonext(). The pipeline ends here.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// 1. Use — passes through to next middleware
app.Use(async (context, next) =>
{
Console.WriteLine($"Before: {context.Request.Path}");
await next();
Console.WriteLine($"After: {context.Response.StatusCode}");
});
// 2. Map — forks on path, terminal
app.Map("/health", async ctx =>
{
await ctx.Response.WriteAsync("OK");
});
// 3. Run — terminal fallback
app.Run(async ctx =>
{
await ctx.Response.WriteAsync("Hello, World!");
});
app.Run();
Simple enough. The trap people fall into is treating the pipeline as a "it just works" abstraction. The order you register middleware is the only thing determining execution flow. Get the order wrong, and you're paying for work that never needed to happen.
UseWhen vs MapWhen: The Branching Difference
This is the single most confused topic in ASP.NET middleware. The names look similar. They work completely differently.
UseWhen creates a temporary branch. If the predicate is true, the branch executes and then rejoins the main pipeline.
MapWhen creates a permanent fork. If the predicate is true, the branch executes and never returns to the main pipeline.
Here's how that looks in practice:
// UseWhen: branch executes, then rejoins main pipeline
app.UseWhen(
context => context.Request.Path.StartsWithSegments("/api"),
branch =>
{
branch.Use(async (context, next) =>
{
Console.WriteLine("API branch entering");
await next();
Console.WriteLine("API branch exiting — pipeline continues");
});
}
);
// After UseWhen, all middleware below still runs
app.UseAuthentication();
app.UseAuthorization();
// ---
// MapWhen: permanent fork, main pipeline is abandoned
app.MapWhen(
context => context.Request.Host.Value?.Contains("admin") == true,
branch =>
{
branch.Use(async (context, next) =>
{
Console.WriteLine("Admin subdomain branch");
await next();
});
// This is the terminal endpoint for the fork
branch.Run(async ctx =>
{
await ctx.Response.WriteAsync("Admin panel");
});
}
);
// ❌ If MapWhen fires, nothing below here runs
app.UseAuthentication();
Real-world guidance: Use UseWhen when you want to layer conditional behavior (e.g., enable request logging only in development, then let the rest of the pipeline continue). Use MapWhen when you need a completely separate pipeline for a subset of traffic (e.g., a subdomain, a health check endpoint, a WebSocket handler).
Conditional activation based on environment is a common pattern (source):
if (app.Environment.IsDevelopment())
{
app.UseHsts(); // Only in dev — maybe too aggressive for dev
}
Factory-Based Activation with IMiddleware
Convention-based middleware (the inline app.Use(async ...) pattern) is activated once at application startup. That's fine for stateless logic. But if your middleware needs scoped services — a database context, an IHttpClientFactory — you run into a classic DI lifetime mismatch: scoped dependencies trapped inside a singleton-activated middleware. The result? Memory leaks (source).
The fix is implementing IMiddleware, which activates the middleware per request through the DI container:
public class AuditMiddleware : IMiddleware
{
private readonly ILogger<AuditMiddleware> _logger;
private readonly IRepository _repository;
// Scoped services are safe here — fresh instance per request
public AuditMiddleware(ILogger<AuditMiddleware> logger, IRepository repository)
{
_logger = logger;
_repository = repository;
}
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
var sw = Stopwatch.StartNew();
await next();
sw.Stop();
_logger.LogInformation(
"{Method} {Path} completed in {ElapsedMs}ms",
context.Request.Method,
context.Request.Path,
sw.ElapsedMilliseconds);
}
}
Registration looks a little different — you register the middleware class as a service before adding it to the pipeline:
var builder = WebApplication.CreateBuilder(args);
// Register in DI
builder.Services.AddTransient<AuditMiddleware>();
var app = builder.Build();
// Activate via UseMiddleware
app.UseMiddleware<AuditMiddleware>();
app.Run();
The tradeoff? Factory activation creates a new middleware instance per request, so there's a small overhead. For high-throughput services, that matters. For most apps, the safety of correct DI lifetimes is worth it (source).
.NET 9 New Features
.NET 9 didn't rewrite the middleware model — it tightened several rough edges. Here's what changed:
MapStaticAssets
MapStaticAssets replaces UseStaticFiles() with built-in compression and ETag support:
// Old approach
app.UseStaticFiles();
// .NET 9 — auto-compresses, adds ETags, better caching
app.MapStaticAssets();
Under the hood, it handles Brotli/Gzip compression and generates ETags so browsers can negotiate 304 responses. No extra configuration needed for the common case (source).
Improved IExceptionHandler
Exception handlers got a StatusCodeSelector attribute, making it easier to route exceptions to specific handlers by HTTP status code:
builder.Services.AddExceptionHandler<AppExceptionHandler>();
// Inside the handler:
public class AppExceptionHandler : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext, Exception exception,
CancellationToken cancellationToken)
{
httpContext.Response.StatusCode = exception switch
{
NotFoundException => StatusCodes.Status404NotFound,
ValidationException => StatusCodes.Status422UnprocessableEntity,
_ => StatusCodes.Status500InternalServerError
};
await httpContext.Response.WriteAsJsonAsync(
new { error = exception.Message }, cancellationToken);
return true; // handled
}
}
Route Group Improvements
Route groups now support ProducesProblem consistently, so your API's error shape stays uniform without repeating yourself at every endpoint.
Performance Tips That Actually Matter
The middleware pipeline is where performance lives or dies. Not your database query, not your serializer — the pipeline, because it runs on every single request. These patterns consistently show up in production diagnostics:
1. Short-circuit early
If you can return a response without calling next(), do it immediately. Every skipped middleware is CPU cycles not spent.
app.Use(async (context, next) =>
{
// Cheap check first — skip the entire pipeline for OPTIONS
if (context.Request.Method == HttpMethods.Options)
{
context.Response.StatusCode = StatusCodes.Status204NoContent;
return; // no next() — short-circuit
}
await next();
});
2. Order is everything
Put cheap, high-skip-rate middleware first. Authentication, CORS, routing — these should run before expensive operations like request body parsing or logging.
// ✅ Good order — cheapest first
app.UseExceptionHandler(); // only fires on errors
app.UseHttpsRedirection(); // cheap redirect
app.UseRouting(); // essential
app.UseAuthentication(); // may short-circuit unauthorized
app.UseAuthorization(); // same
app.UseCustomAudit(); // expensive — last
3. Never sync-over-async
This one is non-negotiable. Using .Result, .Wait(), or GetAwaiter().GetResult() inside middleware will deadlock your thread pool. Always await:
// ❌ BAD — blocks thread pool
app.Use(async (context, next) =>
{
var data = GetSomethingAsync().Result;
await next();
});
// ✅ GOOD — truly asynchronous
app.Use(async (context, next) =>
{
var data = await GetSomethingAsync();
await next();
});
4. Avoid buffering large request bodies
Middleware that reads context.Request.Body into memory forces the entire body to be buffered. For file uploads or large payloads, process the stream directly or set a reasonable body size limit (source).
5. Go end-to-end async
Every layer of your stack should be async — database calls, HTTP client requests, serialization. If one piece sync-blocks, it bottlenecks the whole pipeline.
Wrapping Up
The middleware pipeline isn't mysterious — it's just functions in order. But the small decisions compound: where you short-circuit, how you activate middleware, whether you're accidentally capturing scoped services. Get those right, and your app runs tighter.
.NET 9 made it easier with MapStaticAssets, cleaner exception handling, and better route group support. But the fundamentals — order, async discipline, short-circuiting — haven't changed since ASP.NET Core 1.0.
What's the middleware mistake you've seen (or made) in production? Drop it in the comments — no judgment, we've all captured a scoped dependency in a singleton at least once. 😅
Top comments (0)