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.
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:
-
Inline middleware with
app.Use()- passes to next middleware -
Terminal middleware with
app.Run()- short-circuits the pipeline - 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();
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!");
});
Console output:
Middleware 1: Incoming request
Middleware 2: Incoming request
Middleware 3: Terminal - handling request
Middleware 2: Outgoing response
Middleware 1: Outgoing response
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();
Routing Middleware
Maps incoming requests to endpoints. In .NET 10 with minimal APIs, routing is implicit.
app.UseRouting();
Authentication and Authorization
Establishes user identity and enforces access policies. Always register authentication before authorization.
app.UseAuthentication();
app.UseAuthorization();
HTTPS Redirection
Redirects HTTP requests to HTTPS.
app.UseHttpsRedirection();
CORS Middleware
Controls cross-origin request access for browser-based clients.
app.UseCors("AllowFrontend");
Response Compression Middleware
Compresses responses using gzip or Brotli to reduce bandwidth.
app.UseResponseCompression();
Output Caching Middleware (.NET 10)
Server-side response caching with tag-based invalidation.
app.UseOutputCache();
Rate Limiting Middleware (.NET 10)
Built-in rate limiting with fixed window, sliding window, token bucket, and concurrency algorithms.
app.UseRateLimiter();
Custom Middleware: Patterns and Best Practices
Convention-Based Middleware
This is the most common approach for custom middleware. A convention-based middleware class must:
- Accept a
RequestDelegatein its constructor (represents the next middleware) - Implement an
InvokeAsyncmethod that takesHttpContextand returnsTask - 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);
}
}
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();
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);
}
}
}
Registration:
// Step 1: Register in DI container
builder.Services.AddTransient<CorrelationIdMiddleware>();
// Step 2: Add to pipeline
app.UseMiddleware<CorrelationIdMiddleware>();
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");
});
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}'");
})
);
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>()
);
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);
}
}
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();
});
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
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();
});
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();
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
}
}
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
Security Pipeline
app.UseHttpsRedirection(); // Force HTTPS
app.UseCors("AllowFrontend"); // CORS headers
app.UseAuthentication(); // Establish identity
app.UseAuthorization(); // Check permissions
app.UseRateLimiter(); // Rate limiting
Performance Pipeline
app.UseResponseCompression(); // Compress responses
app.UseOutputCache(); // Server-side caching
app.UseStaticFiles(); // Serve static assets
app.UseRouting(); // Route matching
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.

Top comments (0)