ASP.NET Core Middleware Pipeline: A Deep Dive

11 min readMarch 9, 2026
ASP.NET Core middleware.NET middleware pipelineCustom middleware C#ProblemDetails .NETRate limiting .NETException handling middlewareMiddleware ordering.NET request pipeline

# 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.

csharp
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:

csharp
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:

csharp
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`:

csharp
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:

csharp
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.

csharp
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`, etc.) and `InvokeAsync` parameters for scoped and transient dependencies.

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:

csharp
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:

csharp
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.

csharp
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:

csharp
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:

csharp
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:

csharp
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.

csharp
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:

csharp
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:

csharp
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:

csharp
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:

csharp
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

Have a Flutter Project?

I build high-performance Flutter applications for iOS, Android, and web.

Get in Touch