You're Probably Writing ASP.NET Core Middleware Wrong
ASP.NET Core Middleware is one of the most powerful features of the modern .NET web stack. It provides you with God-like power over the HTTP request pipeline, allowing you to intercept, modify, or reject every single request that hits your server.
However, with great power comes great responsibility. Because middleware sits directly in the hot path of your application, it is also the easiest place in a .NET codebase to accidentally crater your performance, create impossible-to-debug memory leaks, or introduce severe security flaws.
After reviewing countless Pull Requests and debugging production outages, I have compiled the ultimate guide on how middleware actually works, the critical mistakes developers make, and how to fix them.
Table of Contents
- 1. The Russian Nesting Doll Architecture
- 2. Mistake 1: Blocking the Async Pipeline (Thread Starvation)
- 3. Mistake 2: Swallowing Exceptions and Breaking the Chain
- 4. Mistake 3: Capturing Scoped Services in a Singleton
- 5. Mistake 4: Ordering Issues in the Pipeline
- 6. Modern Alternative: IExceptionHandler in .NET 8
- 7. Middleware vs Action Filters
- Final Word
1. The Russian Nesting Doll Architecture
To understand how to write good middleware, you first have to understand the architecture. Middleware in ASP.NET Core is essentially a Russian nesting doll.
Every HTTP request that hits Kestrel (the web server) has to pass through a series of middleware components (the dolls) sequentially. The first middleware can inspect the request, modify it, and then pass it to the next middleware by calling await _next(context).
Eventually, the request hits your actual endpoint (like an MVC Controller or a Minimal API). The endpoint generates a response, and that response bubbles all the way back out through those exact same middleware components in reverse order.
Itβs an elegant pattern for handling cross-cutting concerns like logging, authentication, CORS, and error handling. But let's look at how developers break it.
2. Mistake 1: Blocking the Async Pipeline (Thread Starvation)
Middleware sits directly in the hot path of every single HTTP request. If you block the executing thread here, you choke the entire web server.
I recently debugged a production outage caused by a custom API Key validation middleware that looked something like this:
// π¨ DISASTER WAITING TO HAPPEN
public async Task InvokeAsync(HttpContext context)
{
var apiKey = context.Request.Headers["X-API-Key"].FirstOrDefault();
// .Result blocks the thread synchronously!
var isValid = _authService.ValidateKeyAsync(apiKey).Result;
if (!isValid)
{
context.Response.StatusCode = 401;
return; // Short-circuits the pipeline
}
await _next(context);
}
Using .Result or .Wait() in middleware causes Thread Pool Starvation.
Under heavy load, ASP.NET Core assigns threads from the Thread Pool to handle incoming HTTP requests. If you use .Result, the thread stops executing and waits synchronously for the database or network call to finish. Meanwhile, new requests keep pouring in, exhausting the Thread Pool. Eventually, the server stops responding entirely and you experience a catastrophic outage.
The Fix: You must always await your async calls. This returns the thread to the pool while the I/O operation happens, keeping your server highly concurrent and scalable. (Read more in my guide on Async/Await Best Practices).
3. Mistake 2: Swallowing Exceptions and Breaking the Chain
When an exception occurs deep inside your application, you want the default UseExceptionHandler or your custom logging middleware at the top of the pipeline to catch it, log the full stack trace, and return a sanitized 500 Internal Server Error to the client.
But I frequently see custom try/catch blocks in middleware that swallow the error and return a generic string without logging anything. Worse, developers sometimes forget to call await _next(context) on the happy path, dropping the request into a black hole.
public async Task InvokeAsync(HttpContext context)
{
try
{
// Do some custom logic
LogRequest(context);
// FORGETTING THIS LINE breaks the entire app! Every request hangs.
// await _next(context);
}
catch (Exception ex)
{
// π¨ Anti-pattern: Swallowing the exception and returning a vague error
context.Response.StatusCode = 500;
await context.Response.WriteAsync("Something went wrong.");
}
}
The Fix: Do not write global exception handlers from scratch using middleware unless you absolutely have to. Just use the built-in exception handling mechanics, or rely on .NET 8's newer interfaces (discussed below).
4. Mistake 3: Capturing Scoped Services in a Singleton
This is the most subtle, yet destructive bug you can introduce into an ASP.NET Core application regarding Dependency Injection.
When you register a middleware using the convention-based approach (creating a class with an InvokeAsync method and registering it with app.UseMiddleware<MyMiddleware>()), the middleware class is instantiated as a Singleton. It lives for the lifetime of the application.
If you try to inject a Scoped service (like an Entity Framework Core DbContext) into the constructor of a Singleton middleware, you have effectively turned that DbContext into a Singleton.
public class TenantMiddleware
{
private readonly RequestDelegate _next;
private readonly ApplicationDbContext _db; // π¨ Danger! Captured Scoped Service!
// Constructor injection in convention middleware happens ONCE at startup
public TenantMiddleware(RequestDelegate next, ApplicationDbContext db)
{
_next = next;
_db = db;
}
public async Task InvokeAsync(HttpContext context)
{
// By request 100, this DbContext is tracking a massive amount of data.
// It is not thread-safe and will eventually throw a concurrency exception or OOM.
var tenant = await _db.Tenants.FindAsync(context.Request.Headers["TenantId"]);
await _next(context);
}
}
The Fix: You must inject Scoped and Transient services into the InvokeAsync method itself, not the constructor. The constructor is strictly for Singletons.
public class TenantMiddleware
{
private readonly RequestDelegate _next;
public TenantMiddleware(RequestDelegate next) // Only Singletons here
{
_next = next;
}
// β
Inject Scoped services into the method signature!
public async Task InvokeAsync(HttpContext context, ApplicationDbContext db)
{
var tenant = await db.Tenants.FindAsync(context.Request.Headers["TenantId"]);
await _next(context);
}
}
Alternatively, just implement the IMiddleware interface. This forces the middleware itself to be registered as a Scoped dependency in your DI container, completely sidestepping the captive dependency issue.
5. Mistake 4: Ordering Issues in the Pipeline
Because middleware executes sequentially, the order in which you add them to your Program.cs file is critically important.
If you put app.UseAuthorization() before app.UseAuthentication(), your authorization checks will always fail because the user hasn't been identified yet. If you put your custom logging middleware after app.UseExceptionHandler(), your logger won't record any unhandled exceptions because the exception handler short-circuits the pipeline and returns the response before the logger ever sees it.
Always adhere to the standard ASP.NET Core middleware ordering guidelines: Exception Handling -> HSTS -> HTTPS Redirection -> Static Files -> Routing -> CORS -> Authentication -> Authorization -> Custom Middleware -> Endpoints.
6. Modern Alternative: IExceptionHandler in .NET 8
If you are building an app with .NET 8 (and you care about .NET 8 Performance), you should stop writing custom exception-handling middleware entirely.
Microsoft introduced the IExceptionHandler interface, which integrates directly into the framework's default exception handling pipeline. It is much safer, cleaner, and adheres to standard DI practices automatically.
7. Middleware vs Action Filters
A common question is: Should I use Middleware or an Action Filter?
Use Middleware when: You need to manipulate the raw HTTP request before ASP.NET Core even attempts to route it. Examples: CORS, HTTPS redirection, global request logging, API key validation.
Use Action Filters when: You need access to MVC/API-specific context, like Model State, routing data, or the specific Controller/Endpoint being executed. Examples: Model validation, endpoint-specific authorization.
Final Word
Writing custom middleware gives you incredible control over the HTTP pipeline. But with that power comes the responsibility of not tanking your server's throughput.
Keep your code fully asynchronous, inject scoped dependencies into the InvokeAsync method signature, and strictly manage the order of your pipeline. If you can accomplish your goal using a targeted Action Filter or an Endpoint Filter instead, you probably should.
Tired of writing boilerplate C#? Whether you are writing DTOs for your endpoints or models for your custom middleware, stop doing it by hand. Check out my completely free JSON to C# Converter to instantly generate your C# classes from JSON payloads right in your browser. No strings attached, no data saved to the cloud.