DEV Community

Vikrant Bagal
Vikrant Bagal

Posted on

.NET 9 Middleware Pipeline: Advanced Patterns and Performance 🚀

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 calls next() to continue down the pipeline
  • Map — forks the pipeline based on a URL path
  • Run — terminal. No next(). 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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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)