ASP.NET Core Middleware Pipeline: Order, Custom Middleware, and Best Practices
Introduction
The middleware pipeline is the heart of ASP.NET Core request processing. Every HTTP request flows through a series of middleware components that can read, modify, or short-circuit the request and response. Understanding middleware order, creating custom components, and optimizing the pipeline is essential for building performant and maintainable applications.
Middleware Pipeline Basics
Request Processing Flow
Pipeline execution:
Client Request
↓
Exception Handling Middleware
↓
HTTPS Redirection
↓
Static Files
↓
Authentication
↓
Authorization
↓
Custom Middleware
↓
Routing
↓
Endpoint Middleware
↓
Application Code
↓
Response
↓
Back through middleware chain
↓
Client Response
Standard Middleware Order
Program.cs configuration:
var app = builder.Build();
// 1. Exception handling - must be first
app.UseExceptionHandler("/error");
app.UseHsts();
// 2. HTTPS redirection
app.UseHttpsRedirection();
// 3. Static files - early to short-circuit
app.UseStaticFiles();
// 4. Routing - matches endpoints
app.UseRouting();
// 5. CORS - after routing, before auth
app.UseCors("AllowAll");
// 6. Authentication - identifies user
app.UseAuthentication();
// 7. Authorization - verifies permissions
app.UseAuthorization();
// 8. Custom middleware
app.UseMiddleware<RequestTimingMiddleware>();
// 9. Session - after authentication
app.UseSession();
// 10. Response caching
app.UseResponseCaching();
// 11. Response compression - near the end
app.UseResponseCompression();
// 12. Endpoints - must be last
app.MapControllers();
app.MapRazorPages();
app.Run();
Custom Middleware
Inline Middleware
Simple inline middleware:
app.Use(async (context, next) =>
{
// Before next middleware
Console.WriteLine($"Request: {context.Request.Path}");
await next(context);
// After next middleware
Console.WriteLine($"Response: {context.Response.StatusCode}");
});
Convention-Based Middleware
Request timing middleware:
public class RequestTimingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestTimingMiddleware> _logger;
public RequestTimingMiddleware(
RequestDelegate next,
ILogger<RequestTimingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var sw = Stopwatch.StartNew();
try
{
await _next(context);
}
finally
{
sw.Stop();
_logger.LogInformation(
"Request {Method} {Path} completed in {ElapsedMs}ms with status {StatusCode}",
context.Request.Method,
context.Request.Path,
sw.ElapsedMilliseconds,
context.Response.StatusCode);
}
}
}
// Extension method
public static class RequestTimingMiddlewareExtensions
{
public static IApplicationBuilder UseRequestTiming(
this IApplicationBuilder builder)
{
return builder.UseMiddleware<RequestTimingMiddleware>();
}
}
// Usage
app.UseRequestTiming();
Factory-Based Middleware
With dependencies:
public class ApiKeyMiddleware : IMiddleware
{
private readonly ILogger<ApiKeyMiddleware> _logger;
private readonly IApiKeyValidator _validator;
public ApiKeyMiddleware(
ILogger<ApiKeyMiddleware> logger,
IApiKeyValidator validator)
{
_logger = logger;
_validator = validator;
}
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
if (!context.Request.Headers.TryGetValue("X-API-Key", out var apiKey))
{
context.Response.StatusCode = 401;
await context.Response.WriteAsync("API Key missing");
return;
}
if (!await _validator.IsValidAsync(apiKey!))
{
_logger.LogWarning("Invalid API key: {ApiKey}", apiKey);
context.Response.StatusCode = 401;
await context.Response.WriteAsync("Invalid API Key");
return;
}
await next(context);
}
}
// Registration
builder.Services.AddScoped<ApiKeyMiddleware>();
builder.Services.AddScoped<IApiKeyValidator, ApiKeyValidator>();
// Usage
app.UseMiddleware<ApiKeyMiddleware>();
Request and Response Modification
Reading Request Body
Buffering for multiple reads:
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)
{
// Enable buffering to read request body multiple times
context.Request.EnableBuffering();
string requestBody = string.Empty;
if (context.Request.ContentLength > 0)
{
using var reader = new StreamReader(
context.Request.Body,
encoding: Encoding.UTF8,
detectEncodingFromByteOrderMarks: false,
bufferSize: 1024,
leaveOpen: true);
requestBody = await reader.ReadToEndAsync();
// Reset position for next middleware
context.Request.Body.Position = 0;
}
_logger.LogInformation(
"Request {Method} {Path}: {Body}",
context.Request.Method,
context.Request.Path,
requestBody);
await _next(context);
}
}
Modifying Response
Response wrapping:
public class ResponseWrapperMiddleware
{
private readonly RequestDelegate _next;
public ResponseWrapperMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
var originalBody = context.Response.Body;
try
{
using var memoryStream = new MemoryStream();
context.Response.Body = memoryStream;
await _next(context);
memoryStream.Position = 0;
var responseBody = await new StreamReader(memoryStream).ReadToEndAsync();
var wrappedResponse = new
{
success = context.Response.StatusCode < 400,
statusCode = context.Response.StatusCode,
data = responseBody,
timestamp = DateTime.UtcNow
};
var json = JsonSerializer.Serialize(wrappedResponse);
var bytes = Encoding.UTF8.GetBytes(json);
context.Response.Body = originalBody;
context.Response.ContentLength = bytes.Length;
context.Response.ContentType = "application/json";
await context.Response.Body.WriteAsync(bytes);
}
finally
{
context.Response.Body = originalBody;
}
}
}
Conditional Middleware
UseWhen for Branching
Conditional execution:
// Apply middleware only to API requests
app.UseWhen(
context => context.Request.Path.StartsWithSegments("/api"),
appBuilder =>
{
appBuilder.UseMiddleware<ApiKeyMiddleware>();
appBuilder.UseMiddleware<RateLimitingMiddleware>();
});
// Different middleware for admin paths
app.UseWhen(
context => context.Request.Path.StartsWithSegments("/admin"),
appBuilder =>
{
appBuilder.UseMiddleware<AdminAuthenticationMiddleware>();
appBuilder.UseMiddleware<AuditLoggingMiddleware>();
});
MapWhen for Branching
Separate pipeline:
app.MapWhen(
context => context.Request.Path.StartsWithSegments("/health"),
appBuilder =>
{
// Health check pipeline - minimal middleware
appBuilder.Run(async context =>
{
context.Response.ContentType = "application/json";
await context.Response.WriteAsync("{\"status\":\"healthy\"}");
});
});
// Main pipeline continues for non-health requests
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
Authentication and Authorization Flow
JWT Authentication Middleware
Custom JWT validation:
public class JwtMiddleware
{
private readonly RequestDelegate _next;
private readonly IConfiguration _configuration;
public JwtMiddleware(RequestDelegate next, IConfiguration configuration)
{
_next = next;
_configuration = configuration;
}
public async Task InvokeAsync(HttpContext context, IUserService userService)
{
var token = context.Request.Headers["Authorization"]
.FirstOrDefault()?.Split(" ").Last();
if (token != null)
{
await AttachUserToContext(context, userService, token);
}
await _next(context);
}
private async Task AttachUserToContext(
HttpContext context,
IUserService userService,
string token)
{
try
{
var tokenHandler = new JwtSecurityTokenHandler();
var key = Encoding.ASCII.GetBytes(_configuration["Jwt:Secret"]!);
tokenHandler.ValidateToken(token, new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(key),
ValidateIssuer = true,
ValidIssuer = _configuration["Jwt:Issuer"],
ValidateAudience = true,
ValidAudience = _configuration["Jwt:Audience"],
ClockSkew = TimeSpan.Zero
}, out SecurityToken validatedToken);
var jwtToken = (JwtSecurityToken)validatedToken;
var userId = int.Parse(jwtToken.Claims.First(x => x.Type == "id").Value);
// Attach user to context
context.Items["User"] = await userService.GetByIdAsync(userId);
}
catch
{
// Token validation failed - user not attached to context
}
}
}
// Attribute for protected endpoints
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class AuthorizeAttribute : Attribute, IAuthorizationFilter
{
public void OnAuthorization(AuthorizationFilterContext context)
{
var user = context.HttpContext.Items["User"];
if (user == null)
{
context.Result = new JsonResult(new { message = "Unauthorized" })
{
StatusCode = StatusCodes.Status401Unauthorized
};
}
}
}
Error Handling
Global Exception Handler
Centralized error handling:
public class GlobalExceptionMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<GlobalExceptionMiddleware> _logger;
private readonly IHostEnvironment _env;
public GlobalExceptionMiddleware(
RequestDelegate next,
ILogger<GlobalExceptionMiddleware> logger,
IHostEnvironment env)
{
_next = next;
_logger = logger;
_env = env;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception");
await HandleExceptionAsync(context, ex);
}
}
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
{
context.Response.ContentType = "application/json";
var response = exception switch
{
ValidationException => (
StatusCodes.Status400BadRequest,
new { error = exception.Message, errors = ((ValidationException)exception).Errors }
),
NotFoundException => (
StatusCodes.Status404NotFound,
new { error = exception.Message }
),
UnauthorizedException => (
StatusCodes.Status401Unauthorized,
new { error = "Unauthorized" }
),
_ => (
StatusCodes.Status500InternalServerError,
new { error = _env.IsDevelopment() ? exception.ToString() : "Internal server error" }
)
};
context.Response.StatusCode = response.Item1;
await context.Response.WriteAsJsonAsync(response.Item2);
}
}
Status Code Pages
Custom error responses:
app.UseStatusCodePages(async context =>
{
var response = context.HttpContext.Response;
if (response.StatusCode == 404)
{
response.ContentType = "application/json";
await response.WriteAsJsonAsync(new
{
statusCode = 404,
message = "Resource not found"
});
}
else if (response.StatusCode >= 400 && response.StatusCode < 500)
{
response.ContentType = "application/json";
await response.WriteAsJsonAsync(new
{
statusCode = response.StatusCode,
message = "Client error"
});
}
});
Short-Circuiting
Terminal Middleware
Stopping pipeline execution:
// Maintenance mode check
app.Use(async (context, next) =>
{
if (IsMaintenanceMode())
{
context.Response.StatusCode = 503;
await context.Response.WriteAsync("Service under maintenance");
return; // Don't call next
}
await next(context);
});
// Health check endpoint
app.Map("/health", appBuilder =>
{
appBuilder.Run(async context =>
{
await context.Response.WriteAsync("Healthy");
// Pipeline ends here for /health requests
});
});
Performance Considerations
Response Caching
Caching middleware:
builder.Services.AddResponseCaching();
var app = builder.Build();
app.UseResponseCaching();
// Use with attributes
[ResponseCache(Duration = 60, Location = ResponseCacheLocation.Any)]
public IActionResult GetProducts()
{
return Ok(_products);
}
Response Compression
Enable compression:
builder.Services.AddResponseCompression(options =>
{
options.EnableForHttps = true;
options.Providers.Add<BrotliCompressionProvider>();
options.Providers.Add<GzipCompressionProvider>();
});
builder.Services.Configure<BrotliCompressionProviderOptions>(options =>
{
options.Level = CompressionLevel.Fastest;
});
var app = builder.Build();
// Near end of pipeline
app.UseResponseCompression();
Best Practices
- Order Matters: Exception handling first, endpoints last
- Short-Circuit Early: Static files and health checks before expensive middleware
- Use Extension Methods: Encapsulate middleware registration
- Minimize Buffering: Read request/response bodies only when necessary
- Leverage Scoped Services: Use
IMiddlewarefor dependency injection - Test Middleware: Unit test with
DefaultHttpContext - Monitor Performance: Track middleware execution time
Troubleshooting
Middleware Not Executing:
// ❌ Order matters - endpoints terminate pipeline
app.MapControllers();
app.UseMiddleware<MyMiddleware>(); // Won't execute!
// ✅ Correct order
app.UseMiddleware<MyMiddleware>();
app.MapControllers();
Request Body Already Read:
// ✅ Enable buffering for multiple reads
context.Request.EnableBuffering();
Key Takeaways
- Middleware order is critical for correct request processing
- Custom middleware enables cross-cutting concerns like logging and authentication
- Request/response modification requires careful stream handling
- Conditional middleware reduces overhead for specific request paths
- Short-circuiting improves performance for simple endpoints
Next Steps
- Implement health checks with custom middleware
- Add correlation IDs for distributed tracing
- Build rate limiting middleware with Redis
- Create API versioning middleware
Additional Resources
The pipeline is the application.