DEV Community

Cover image for ASP.NET Middleware: Complete Guide from Basics to Advanced Patterns, Tips, and Performance
Vikrant Bagal
Vikrant Bagal

Posted on

ASP.NET Middleware: Complete Guide from Basics to Advanced Patterns, Tips, and Performance

The ASP.NET Core middleware pipeline is the backbone of every HTTP request your application processes. Yet most developers only scratch the surface—registering UseAuthentication() and UseAuthorization() without understanding what actually happens in between. This comprehensive guide takes you from fundamental concepts to advanced patterns, performance optimization, and real-world usage scenarios.

What is ASP.NET Middleware?

Middleware in ASP.NET Core is software that's assembled into an app pipeline to handle requests and responses. Each middleware component:

  • Chooses whether to pass the request to the next middleware
  • Can perform work before and after the next middleware
  • Can short-circuit the pipeline entirely

Think of it as an assembly line: each station does one specific job (logging, authentication, CORS headers, compression), and the request moves from station to station. The response then travels back through the same chain in reverse order.

ASP.NET Middleware

The Middleware Pipeline Basics

Request Delegates and HttpContext

At the core of every middleware component are request delegates—functions that process HTTP requests. There are three ways to define them:

  1. Inline middleware with app.Use() - passes to next middleware
  2. Terminal middleware with app.Run() - short-circuits the pipeline
  3. Convention-based middleware classes - reusable components

The HttpContext object carries everything about the current HTTP request and response. Every middleware receives it, and it's how components communicate:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.Use(async (context, next) =>
{
    Console.WriteLine($"Before: {context.Request.Path}");
    await next();  // Call next middleware
    Console.WriteLine($"After: {context.Response.StatusCode}");
});

app.Run(async context =>
{
    await context.Response.WriteAsync("Hello, World!");
});

app.Run();
Enter fullscreen mode Exit fullscreen mode

Execution Order: The Golden Rule

Registration order = execution order. This is the most critical concept. The request flows forward through middleware, the response flows backward. Get the order wrong, and your application silently misbehaves.

Here's what happens when you register three middleware components:

app.Use(async (context, next) =>
{
    Console.WriteLine("Middleware 1: Incoming request");
    await next();
    Console.WriteLine("Middleware 1: Outgoing response");
});

app.Use(async (context, next) =>
{
    Console.WriteLine("Middleware 2: Incoming request");
    await next();
    Console.WriteLine("Middleware 2: Outgoing response");
});

app.Run(async context =>
{
    Console.WriteLine("Middleware 3: Terminal - handling request");
    await context.Response.WriteAsync("Hello, world!");
});
Enter fullscreen mode Exit fullscreen mode

Console output:

Middleware 1: Incoming request
Middleware 2: Incoming request
Middleware 3: Terminal - handling request
Middleware 2: Outgoing response
Middleware 1: Outgoing response
Enter fullscreen mode Exit fullscreen mode

Notice the nesting pattern—it's like Russian dolls. Middleware 1 wraps Middleware 2, which wraps Middleware 3.

Common Built-in Middleware in .NET 10

ASP.NET Core ships with middleware for the most common cross-cutting concerns:

Exception Handling Middleware

Catches unhandled exceptions and returns structured error responses.

app.UseExceptionHandler();
Enter fullscreen mode Exit fullscreen mode

Routing Middleware

Maps incoming requests to endpoints. In .NET 10 with minimal APIs, routing is implicit.

app.UseRouting();
Enter fullscreen mode Exit fullscreen mode

Authentication and Authorization

Establishes user identity and enforces access policies. Always register authentication before authorization.

app.UseAuthentication();
app.UseAuthorization();
Enter fullscreen mode Exit fullscreen mode

HTTPS Redirection

Redirects HTTP requests to HTTPS.

app.UseHttpsRedirection();
Enter fullscreen mode Exit fullscreen mode

CORS Middleware

Controls cross-origin request access for browser-based clients.

app.UseCors("AllowFrontend");
Enter fullscreen mode Exit fullscreen mode

Response Compression Middleware

Compresses responses using gzip or Brotli to reduce bandwidth.

app.UseResponseCompression();
Enter fullscreen mode Exit fullscreen mode

Output Caching Middleware (.NET 10)

Server-side response caching with tag-based invalidation.

app.UseOutputCache();
Enter fullscreen mode Exit fullscreen mode

Rate Limiting Middleware (.NET 10)

Built-in rate limiting with fixed window, sliding window, token bucket, and concurrency algorithms.

app.UseRateLimiter();
Enter fullscreen mode Exit fullscreen mode

Custom Middleware: Patterns and Best Practices

Convention-Based Middleware

This is the most common approach for custom middleware. A convention-based middleware class must:

  1. Accept a RequestDelegate in its constructor (represents the next middleware)
  2. Implement an InvokeAsync method that takes HttpContext and returns Task
  3. Call await next(context) to pass control (or skip it to short-circuit)

Request Logging Middleware Example:

public class RequestLoggingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestLoggingMiddleware> _logger;

    public RequestLoggingMiddleware(RequestDelegate next, ILogger<RequestLoggingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var startTime = DateTime.UtcNow;
        _logger.LogInformation("Incoming {Method} {Path}", 
            context.Request.Method, context.Request.Path);

        await _next(context);

        var elapsed = DateTime.UtcNow - startTime;
        _logger.LogInformation("Completed {Method} {Path} with {StatusCode} in {Elapsed}ms",
            context.Request.Method, context.Request.Path, 
            context.Response.StatusCode, elapsed.TotalMilliseconds);
    }
}
Enter fullscreen mode Exit fullscreen mode

Registration:

// Option 1: Direct registration
app.UseMiddleware<RequestLoggingMiddleware>();

// Option 2: Extension method (preferred)
public static class MiddlewareExtensions
{
    public static IApplicationBuilder UseRequestLogging(this IApplicationBuilder app)
    {
        return app.UseMiddleware<RequestLoggingMiddleware>();
    }
}

app.UseRequestLogging();
Enter fullscreen mode Exit fullscreen mode

IMiddleware Interface: DI-Friendly Alternative

Convention-based middleware has a limitation: it's registered as a singleton by default. If you need scoped services (like DbContext), you'll encounter lifetime mismatch issues. The IMiddleware interface solves this:

public class CorrelationIdMiddleware : IMiddleware
{
    private readonly ILogger<CorrelationIdMiddleware> _logger;

    public CorrelationIdMiddleware(ILogger<CorrelationIdMiddleware> logger)
    {
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        var correlationId = context.Request.Headers["X-Correlation-Id"].FirstOrDefault()
            ?? Guid.NewGuid().ToString();

        context.Items["CorrelationId"] = correlationId;
        context.Response.Headers["X-Correlation-Id"] = correlationId;

        using (_logger.BeginScope(new Dictionary<string, object> 
            { ["CorrelationId"] = correlationId }))
        {
            _logger.LogInformation("Request {Path} assigned CorrelationId {CorrelationId}",
                context.Request.Path, correlationId);
            await next(context);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Registration:

// Step 1: Register in DI container
builder.Services.AddTransient<CorrelationIdMiddleware>();

// Step 2: Add to pipeline
app.UseMiddleware<CorrelationIdMiddleware>();
Enter fullscreen mode Exit fullscreen mode

When to use which:

  • Convention-based: Middleware only needs singleton services (ILogger, IConfiguration)
  • IMiddleware: Middleware needs scoped services (DbContext, IHttpClientFactory) or per-request activation

Branching the Pipeline

ASP.NET Core provides three methods for branching the middleware pipeline:

1. Map() - Path-Based Branching

Creates a separate pipeline for matching paths:

app.Map("/health", async ctx =>
{
    await ctx.Response.WriteAsync("OK");
});
Enter fullscreen mode Exit fullscreen mode

2. MapWhen() - Predicate-Based Permanent Fork

Creates a permanent fork based on a predicate:

app.MapWhen(
    context => context.Request.Query.ContainsKey("branch"),
    appBuilder => appBuilder.Run(async context =>
    {
        var branchVer = context.Request.Query["branch"];
        await context.Response.WriteAsync($"Branch used = '{branchVer}'");
    })
);
Enter fullscreen mode Exit fullscreen mode

3. UseWhen() - Conditional Branch That Rejoins

Runs middleware for matching requests, then rejoins the main pipeline:

app.UseWhen(
    context => context.Request.Path.StartsWithSegments("/api"),
    appBuilder => appBuilder.UseMiddleware<RequestLoggingMiddleware>()
);
Enter fullscreen mode Exit fullscreen mode

Key Difference:

  • MapWhen: Creates a permanent fork (main pipeline is abandoned)
  • UseWhen: Temporary branch that rejoins the main pipeline

Short-Circuiting the Pipeline

Short-circuiting means returning a response without calling next(), preventing downstream middleware from executing. This is crucial for performance.

Maintenance Mode Example

public class MaintenanceMiddleware
{
    private readonly RequestDelegate _next;
    private readonly IConfiguration _configuration;

    public MaintenanceMiddleware(RequestDelegate next, IConfiguration configuration)
    {
        _next = next;
        _configuration = configuration;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var isMaintenanceMode = _configuration.GetValue<bool>("MaintenanceMode");
        if (isMaintenanceMode)
        {
            context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
            context.Response.ContentType = "application/json";
            await context.Response.WriteAsJsonAsync(new
            {
                Status = 503,
                Message = "Service is under maintenance. Please try again later."
            });
            return; // Short-circuit - do not call next
        }
        await next(context);
    }
}
Enter fullscreen mode Exit fullscreen mode

Register this before other middleware so it can block all requests during maintenance.

Performance Optimization Tips

1. Short-Circuit Early

If you can return a response without calling next(), do it immediately. Every skipped middleware saves CPU cycles.

app.Use(async (context, next) =>
{
    // Cheap check first — skip 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, and routing should run before expensive operations.

Recommended Order:

app.UseExceptionHandler();        // Only fires on errors
app.UseHttpsRedirection();        // Cheap redirect
app.UseRouting();                 // Essential
app.UseAuthentication();          // May short-circuit unauthorized
app.UseAuthorization();           // Same
app.UseResponseCompression();     // Compress responses
app.UseEndpoints();               // Execute endpoints
Enter fullscreen mode Exit fullscreen mode

3. Never Sync-over-Async

Using .Result, .Wait(), or GetAwaiter().GetResult() inside middleware will deadlock your thread pool.

// ❌ BAD — blocks thread pool
app.Use(async (context, next) =>
{
    var data = GetSomethingAsync().Result;  // Deadlock risk!
    await next();
});

// ✅ GOOD — truly asynchronous
app.Use(async (context, next) =>
{
    var data = await GetSomethingAsync();  // Proper async
    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 reasonable body size limits.

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.

.NET 9 New Features

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

Improved IExceptionHandler

Exception handlers got a StatusCodeSelector attribute:

builder.Services.AddExceptionHandler<AppExceptionHandler>();

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 for uniform error shapes.

Real-World Usage Patterns

Enterprise Logging Pipeline

app.UseRequestLogging();          // Capture request/response details
app.UseCorrelationId();           // Add correlation IDs for tracing
app.UseSerilogRequestLogging();   // Structured logging
app.UseExceptionHandler();        // Global exception handling
Enter fullscreen mode Exit fullscreen mode

Security Pipeline

app.UseHttpsRedirection();        // Force HTTPS
app.UseCors("AllowFrontend");     // CORS headers
app.UseAuthentication();          // Establish identity
app.UseAuthorization();           // Check permissions
app.UseRateLimiter();             // Rate limiting
Enter fullscreen mode Exit fullscreen mode

Performance Pipeline

app.UseResponseCompression();     // Compress responses
app.UseOutputCache();             // Server-side caching
app.UseStaticFiles();             // Serve static assets
app.UseRouting();                 // Route matching
Enter fullscreen mode Exit fullscreen mode

Common Pitfalls and Solutions

1. Wrong Middleware Order

Problem: Authentication before authorization causes random failures.
Solution: Always follow: Authentication → Authorization → Endpoint execution.

2. Sync-over-Async Deadlocks

Problem: Using .Result or .Wait() in middleware blocks thread pool.
Solution: Always use await for async operations.

3. Scoped Services in Singleton Middleware

Problem: Convention-based middleware is singleton; scoped services cause memory leaks.
Solution: Use IMiddleware interface for per-request activation.

4. Not Short-Circuiting

Problem: Middleware calls next() even when it shouldn't, wasting resources.
Solution: Short-circuit early when possible (auth failures, maintenance mode).

5. Reading Large Request Bodies

Problem: Middleware buffering large request bodies into memory.
Solution: Process streams directly or set body size limits.

6. Exception Handling Too Late

Problem: Exception middleware registered after other middleware misses exceptions.
Solution: Register exception handling middleware first in the pipeline.

Conclusion

The ASP.NET Core middleware pipeline is where your application does its real work. Understanding the fundamentals—execution order, branching, short-circuiting—is essential for building robust, performant applications. Follow the recommended patterns, avoid common pitfalls, and leverage .NET 9 improvements for optimal results.

Remember: middleware is the backbone of every HTTP request. Get it right, and your application runs smoothly. Get it wrong, and you'll spend hours debugging subtle issues.


LinkedIn: https://www.linkedin.com/in/vikrant-bagal

Top comments (0)