ASP.NET Core Middleware Pipeline: A Deep Dive
# ASP.NET Core Middleware Deep Dive
Middleware is the backbone of every ASP.NET Core application. Every HTTP request that enters your application passes through a chain of middleware components before it reaches your endpoint, and the response travels back through the same chain in reverse. Understanding this pipeline is not optional — it determines how your application handles authentication, logging, error recovery, and dozens of other cross-cutting concerns.
In production APIs I have built, middleware ordering has been the root cause of some of the most confusing bugs. An authentication middleware placed after CORS causes preflight requests to fail with 401. An exception handler registered after the routing middleware misses exceptions thrown during route matching. These are subtle issues that do not show up in unit tests and only surface under real traffic.
How the Middleware Pipeline Works
The ASP.NET Core middleware pipeline is a series of delegates chained together. Each middleware component receives the `HttpContext` and a reference to the next middleware in the chain. It can do work before calling the next delegate, after calling it, or both. It can also choose not to call the next delegate at all — this is called short-circuiting.
class=class="code-string">"code-comment">// Conceptual model of the pipeline
class=class="code-string">"code-comment">// Request flows IN through middleware class="code-number">1 → class="code-number">2 → class="code-number">3 → endpoint
class=class="code-string">"code-comment">// Response flows OUT through middleware class="code-number">3 → class="code-number">2 → class="code-number">1 → client
app.Use(async (context, next) =>
{
class=class="code-string">"code-comment">// class="code-number">1. Pre-processing: runs on the way IN
Console.WriteLine(class="code-string">"Middleware class="code-number">1: Before");
await next(context); class=class="code-string">"code-comment">// Pass control to the next middleware
class=class="code-string">"code-comment">// class="code-number">2. Post-processing: runs on the way OUT
Console.WriteLine(class="code-string">"Middleware class="code-number">1: After");
});Think of it as a set of nested Russian dolls. The outermost middleware wraps everything, and each subsequent middleware wraps the ones that follow it. The request enters from the outside, passes through each layer, hits the endpoint at the center, and then the response unwinds back through each layer in reverse.
This design gives you two interception points per middleware: one on the way in (before `await next()`) and one on the way out (after `await next()`). A logging middleware, for example, can record the start time before calling next and compute the elapsed duration after.
Built-in Middleware Ordering
The order in which you register middleware in `Program.cs` directly defines the order of execution. Getting this wrong causes bugs that are notoriously difficult to diagnose.
Here is the recommended ordering for a typical production API:
var builder = WebApplication.CreateBuilder(args);
class=class="code-string">"code-comment">// Service registration...
var app = builder.Build();
class=class="code-string">"code-comment">// class="code-number">1. Exception handling — outermost, catches everything
app.UseExceptionHandler();
app.UseStatusCodePages();
class=class="code-string">"code-comment">// class="code-number">2. HSTS and HTTPS redirection
if (!app.Environment.IsDevelopment())
{
app.UseHsts();
}
app.UseHttpsRedirection();
class=class="code-string">"code-comment">// class="code-number">3. Static files (before routing, so static assets bypass auth)
app.UseStaticFiles();
class=class="code-string">"code-comment">// class="code-number">4. Routing — resolves the matched endpoint
app.UseRouting();
class=class="code-string">"code-comment">// class="code-number">5. CORS — must be after routing and before auth
app.UseCors();
class=class="code-string">"code-comment">// class="code-number">6. Authentication — who are you?
app.UseAuthentication();
class=class="code-string">"code-comment">// class="code-number">7. Authorization — are you allowed?
app.UseAuthorization();
class=class="code-string">"code-comment">// class="code-number">8. Rate limiting — after auth so you can apply per-user limits
app.UseRateLimiter();
class=class="code-string">"code-comment">// class="code-number">9. Response caching/compression
app.UseResponseCaching();
app.UseResponseCompression();
class=class="code-string">"code-comment">// class="code-number">10. Custom middleware
class=class="code-string">"code-comment">// class="code-number">11. Endpoint execution
app.MapControllers();
app.Run();Why does the order matter so much? Because `UseExceptionHandler` must be first to catch exceptions from all subsequent middleware. `UseRouting` must come before `UseAuthentication` so that the authentication middleware knows which endpoint was matched and can apply the correct auth policy. `UseCors` must come between routing and authorization — if CORS runs before routing, it cannot determine the matched endpoint's CORS policy, and if it runs after authorization, preflight requests get rejected with 401.
Writing Custom Middleware
Inline Middleware with `app.Use`
The fastest way to add middleware is inline with a lambda. This is good for prototyping and simple scenarios:
app.Use(async (context, next) =>
{
var correlationId = context.Request.Headers[class="code-string">"X-Correlation-Id"].FirstOrDefault()
?? Guid.NewGuid().ToString();
context.Response.Headers[class="code-string">"X-Correlation-Id"] = correlationId;
context.Items[class="code-string">"CorrelationId"] = correlationId;
await next(context);
});Class-Based Middleware
For anything beyond trivial logic, extract middleware into its own class. ASP.NET Core middleware follows a convention: a constructor that accepts `RequestDelegate`, and an `InvokeAsync` method that takes `HttpContext`:
public class CorrelationIdMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<CorrelationIdMiddleware> _logger;
public CorrelationIdMiddleware(RequestDelegate next, ILogger<CorrelationIdMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var correlationId = context.Request.Headers[class="code-string">"X-Correlation-Id"].FirstOrDefault()
?? Guid.NewGuid().ToString();
context.Items[class="code-string">"CorrelationId"] = correlationId;
context.Response.OnStarting(() =>
{
context.Response.Headers[class="code-string">"X-Correlation-Id"] = correlationId;
return Task.CompletedTask;
});
using (_logger.BeginScope(new Dictionary<string, object>
{
[class="code-string">"CorrelationId"] = correlationId
}))
{
await _next(context);
}
}
}
class=class="code-string">"code-comment">// Registration in Program.cs
app.UseMiddleware<CorrelationIdMiddleware>();Notice a subtle detail: I use `context.Response.OnStarting` instead of setting the header directly before calling `next`. This ensures the header is set just before the response headers are sent, which avoids issues if a later middleware modifies or resets headers.
Convention-Based Middleware with Extension Methods
Production-grade middleware should expose an extension method for clean registration:
public static class MiddlewareExtensions
{
public static IApplicationBuilder UseCorrelationId(this IApplicationBuilder app)
{
return app.UseMiddleware<CorrelationIdMiddleware>();
}
public static IApplicationBuilder UseRequestTiming(this IApplicationBuilder app)
{
return app.UseMiddleware<RequestTimingMiddleware>();
}
}
class=class="code-string">"code-comment">// Clean registration
app.UseCorrelationId();
app.UseRequestTiming();Middleware Dependency Injection
Middleware classes are instantiated once at application startup, making them effectively singletons. This has a critical consequence: you cannot inject scoped or transient services through the constructor.
class=class="code-string">"code-comment">// WRONG — DbContext is scoped, middleware is singleton
public class BadMiddleware
{
private readonly RequestDelegate _next;
private readonly AppDbContext _db; class=class="code-string">"code-comment">// Captive dependency!
public BadMiddleware(RequestDelegate next, AppDbContext db)
{
_next = next;
_db = db; class=class="code-string">"code-comment">// Same instance for every request
}
}
class=class="code-string">"code-comment">// CORRECT — inject scoped services through InvokeAsync
public class GoodMiddleware
{
private readonly RequestDelegate _next;
public GoodMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context, AppDbContext db, IUserService userService)
{
class=class="code-string">"code-comment">// db and userService are resolved per-request from the DI container
var user = await userService.GetCurrentUserAsync(context.User);
await _next(context);
}
}The `InvokeAsync` method supports method injection — ASP.NET Core resolves its parameters from the DI container for each request. Use the constructor for singleton dependencies only (`RequestDelegate`, `ILogger`, `IOptions
Exception Handling Middleware
Built-in ProblemDetails (RFC 7807)
.NET 7+ provides a first-class ProblemDetails integration that standardizes error responses according to RFC 7807. This is the approach I use in every production API:
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
{
context.ProblemDetails.Extensions[class="code-string">"traceId"] = context.HttpContext.TraceIdentifier;
context.ProblemDetails.Extensions[class="code-string">"instance"] = context.HttpContext.Request.Path;
};
});
app.UseExceptionHandler();
app.UseStatusCodePages();Custom Exception Handling Middleware
For more control, write a dedicated exception handler that maps domain exceptions to appropriate HTTP responses:
public class GlobalExceptionMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<GlobalExceptionMiddleware> _logger;
public GlobalExceptionMiddleware(RequestDelegate next, ILogger<GlobalExceptionMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
_logger.LogError(ex, class="code-string">"Unhandled exception for {Method} {Path}",
context.Request.Method, context.Request.Path);
await HandleExceptionAsync(context, ex);
}
}
private static async Task HandleExceptionAsync(HttpContext context, Exception exception)
{
var (statusCode, title) = exception switch
{
ArgumentValidationException => (StatusCodes.Status400BadRequest, class="code-string">"Validation Error"),
NotFoundException => (StatusCodes.Status404NotFound, class="code-string">"Resource Not Found"),
ConflictException => (StatusCodes.Status409Conflict, class="code-string">"Conflict"),
UnauthorizedAccessException => (StatusCodes.Status403Forbidden, class="code-string">"Forbidden"),
_ => (StatusCodes.Status500InternalServerError, class="code-string">"Internal Server Error")
};
context.Response.StatusCode = statusCode;
context.Response.ContentType = class="code-string">"application/problem+json";
var problemDetails = new ProblemDetails
{
Status = statusCode,
Title = title,
Detail = statusCode == class="code-number">500 ? class="code-string">"An unexpected error occurred." : exception.Message,
Type = $class="code-string">"https:class="code-commentclass="code-string">">//httpstatuses.com/{statusCode}",
Extensions =
{
[class="code-string">"traceId"] = context.TraceIdentifier
}
};
await context.Response.WriteAsJsonAsync(problemDetails);
}
}Request/Response Logging Middleware
Logging every request and response is invaluable for debugging and auditing. But be careful — logging request and response bodies can leak sensitive data and consume massive amounts of storage.
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 stopwatch = Stopwatch.StartNew();
var requestPath = context.Request.Path;
var method = context.Request.Method;
_logger.LogInformation(class="code-string">"Request started: {Method} {Path} {QueryString}",
method, requestPath, context.Request.QueryString);
try
{
await _next(context);
stopwatch.Stop();
_logger.LogInformation(
class="code-string">"Request completed: {Method} {Path} responded {StatusCode} in {ElapsedMs}ms",
method, requestPath, context.Response.StatusCode, stopwatch.ElapsedMilliseconds);
}
catch (Exception ex)
{
stopwatch.Stop();
_logger.LogError(ex,
class="code-string">"Request failed: {Method} {Path} threw exception after {ElapsedMs}ms",
method, requestPath, stopwatch.ElapsedMilliseconds);
throw; class=class="code-string">"code-comment">// Re-throw so the exception handling middleware can deal with it
}
}
}In production systems, I always add filtering to skip health check endpoints and static file requests. Without filtering, your logging infrastructure drowns in noise from Kubernetes probes hitting `/health` every few seconds.
Rate Limiting Middleware
.NET 7 introduced built-in rate limiting. Here is a production-ready setup with multiple policies:
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
class=class="code-string">"code-comment">// Global fixed window policy
options.AddFixedWindowLimiter(class="code-string">"fixed", opt =>
{
opt.PermitLimit = class="code-number">100;
opt.Window = TimeSpan.FromMinutes(class="code-number">1);
opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
opt.QueueLimit = class="code-number">10;
});
class=class="code-string">"code-comment">// Per-user sliding window policy
options.AddSlidingWindowLimiter(class="code-string">"per-user", opt =>
{
opt.PermitLimit = class="code-number">30;
opt.Window = TimeSpan.FromMinutes(class="code-number">1);
opt.SegmentsPerWindow = class="code-number">6;
});
class=class="code-string">"code-comment">// Token bucket for API keys
options.AddTokenBucketLimiter(class="code-string">"api-key", opt =>
{
opt.TokenLimit = class="code-number">200;
opt.ReplenishmentPeriod = TimeSpan.FromSeconds(class="code-number">10);
opt.TokensPerPeriod = class="code-number">20;
});
options.OnRejected = async (context, cancellationToken) =>
{
context.HttpContext.Response.ContentType = class="code-string">"application/problem+json";
var problem = new ProblemDetails
{
Status = class="code-number">429,
Title = class="code-string">"Too Many Requests",
Detail = class="code-string">"Rate limit exceeded. Please retry after the reset window."
};
await context.HttpContext.Response.WriteAsJsonAsync(problem, cancellationToken);
};
});
class=class="code-string">"code-comment">// Apply in pipeline — after auth so user identity is available
app.UseRateLimiter();Apply policies to specific endpoints:
app.MapGet(class="code-string">"/api/products", GetProducts)
.RequireRateLimiting(class="code-string">"per-user");
app.MapPost(class="code-string">"/api/orders", CreateOrder)
.RequireRateLimiting(class="code-string">"fixed");Middleware vs Endpoint Filters vs Action Filters
One of the most common questions I get is when to use middleware versus filters. Here is the distinction:
Middleware operates on every request passing through the pipeline. It has no knowledge of MVC concepts like controllers, actions, or model binding. Use it for cross-cutting concerns that apply globally: logging, correlation IDs, exception handling, CORS.
Action Filters are an MVC concept. They run after model binding and have access to `ActionExecutingContext`, which includes action arguments, controller instance, and model state. Use them for controller-specific concerns: auditing which action was called, validating model state, transforming action results.
Endpoint Filters (introduced in .NET 7) are the Minimal API equivalent of action filters. They wrap individual endpoint handlers and have access to the endpoint's arguments:
app.MapPost(class="code-string">"/api/products", CreateProduct)
.AddEndpointFilter(async (context, next) =>
{
var request = context.GetArgument<CreateProductRequest>(class="code-number">0);
if (string.IsNullOrWhiteSpace(request.Name))
{
return Results.ValidationProblem(
new Dictionary<string, string[]>
{
[class="code-string">"Name"] = [class="code-string">"Product name is required."]
});
}
return await next(context);
});| Aspect | Middleware | Action Filters | Endpoint Filters |
|--------|-----------|---------------|-----------------|
| Scope | Every request | MVC actions only | Minimal API endpoints |
| Access to action context | No | Yes | Yes (arguments) |
| Runs before model binding | Yes | No | No |
| Can short-circuit | Yes | Yes | Yes |
| DI support | InvokeAsync params | Constructor | Constructor/lambda |
| Best for | Cross-cutting global concerns | MVC-specific logic | Minimal API validation |
Common Middleware Mistakes
1. Wrong Ordering
The most damaging mistake. Placing `UseAuthentication` before `UseRouting` means the auth middleware cannot determine which endpoint was matched, so endpoint-specific `[Authorize]` policies do not work correctly. Placing `UseExceptionHandler` after your custom middleware means exceptions from that middleware go unhandled.
2. Forgetting to Call `next()`
If your middleware conditionally skips `await next()` without writing a response, the client receives an empty 200 response. This is a legitimate technique for short-circuiting (e.g., returning a cached response), but accidentally forgetting the call creates confusing bugs.
class=class="code-string">"code-comment">// Bug: forgot to call next on the else branch
app.Use(async (context, next) =>
{
if (context.Request.Headers.ContainsKey(class="code-string">"X-Block"))
{
context.Response.StatusCode = class="code-number">403;
await context.Response.WriteAsync(class="code-string">"Blocked");
return; class=class="code-string">"code-comment">// Short-circuit — intentional
}
class=class="code-string">"code-comment">// BUG: if we get here but forget await next(context),
class=class="code-string">"code-comment">// the request silently returns empty class="code-number">200
await next(context); class=class="code-string">"code-comment">// Do not forget this!
});3. Modifying the Response After It Has Started
Once `context.Response.HasStarted` returns true, you cannot modify headers or the status code. This happens when a later middleware or the endpoint has already started writing the response body. Always check `HasStarted` before modifying response headers in post-processing:
app.Use(async (context, next) =>
{
await next(context);
class=class="code-string">"code-comment">// This will throw if the response has already started streaming
if (!context.Response.HasStarted)
{
context.Response.Headers[class="code-string">"X-Custom-Header"] = class="code-string">"value";
}
});4. Heavy Work in Middleware
Middleware runs on every request. Performing database queries, external API calls, or complex computations in middleware decimates your throughput. If you need per-request data enrichment, consider caching results or moving the logic to an action filter that only runs for relevant endpoints.
5. Not Re-Throwing Exceptions
Catching an exception in middleware and not re-throwing it swallows the error silently. Unless you are writing a deliberate exception handler, always re-throw:
class=class="code-string">"code-comment">// WRONG — swallows the exception, no other middleware sees it
app.Use(async (context, next) =>
{
try { await next(context); }
catch (Exception ex)
{
_logger.LogError(ex, class="code-string">"Something went wrong");
class=class="code-string">"code-comment">// Missing: throw; or writing an error response
}
});Conditional Middleware Execution
You can conditionally branch the middleware pipeline based on request properties:
class=class="code-string">"code-comment">// Only apply to API routes
app.UseWhen(
context => context.Request.Path.StartsWithSegments(class="code-string">"/api"),
appBuilder =>
{
appBuilder.UseMiddleware<ApiKeyValidationMiddleware>();
appBuilder.UseMiddleware<RequestLoggingMiddleware>();
});
class=class="code-string">"code-comment">// Map a separate pipeline for health checks
app.Map(class="code-string">"/health", appBuilder =>
{
appBuilder.Run(async context =>
{
context.Response.StatusCode = class="code-number">200;
await context.Response.WriteAsync(class="code-string">"Healthy");
});
});`UseWhen` re-joins the main pipeline after the branch, while `Map` creates a terminal branch that does not re-join.
Terminal Middleware
`app.Run` registers terminal middleware — it does not receive a `next` delegate and always ends the pipeline:
class=class="code-string">"code-comment">// Fallback for unmatched routes
app.Run(async context =>
{
context.Response.StatusCode = class="code-number">404;
context.Response.ContentType = class="code-string">"application/problem+json";
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = class="code-number">404,
Title = class="code-string">"Not Found",
Detail = $class="code-string">"No endpoint matched the path class="code-string">'{context.Request.Path}'."
});
});Conclusion
The middleware pipeline is where your application's cross-cutting behavior lives. Getting the ordering right, understanding the singleton lifecycle, and knowing when to use middleware versus filters separates robust APIs from fragile ones. Every production incident I have investigated that involved middleware came down to one of the mistakes listed above — ordering, missing `next()` calls, or accidental singleton capture of scoped services. Master these fundamentals and the pipeline becomes one of your strongest architectural tools.
I can help design and review your middleware pipeline for production readiness.
Related Articles
Building RESTful APIs with ASP.NET Core
Learn the fundamentals of building production-ready REST APIs with ASP.NET Core. Controllers, routing, and best practices.
Authentication and Authorization in .NET: JWT and Identity
Implement secure authentication and authorization in .NET. JWT, ASP.NET Core Identity, and OAuth2.
Logging and Monitoring in .NET: Serilog and Application Insights
Build effective logging and monitoring strategy in .NET. Serilog, structured logging, and Application Insights.
Have a Flutter Project?
I build high-performance Flutter applications for iOS, Android, and web.
Get in Touch