/**/

You're Probably Writing ASP.NET Core Middleware Wrong

4 min readAjay Patel

Middleware in ASP.NET Core is essentially a Russian nesting doll. Every HTTP request that hits your server has to pass through a series of middleware components (the dolls) until it hits your endpoint, and then the response bubbles all the way back out through those same components.

It’s an elegant pattern for handling cross-cutting concerns like logging, authentication, and error handling. But it is also the easiest place in a .NET application to accidentally crater your performance or create impossible-to-debug memory leaks.

After reviewing countless PRs, here are the three most common middleware mistakes I see developers make, and how to fix them.

1. Blocking the Async Pipeline (The Cardinal Sin)

Middleware sits directly in the hot path of every single HTTP request. If you block the 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!
    var isValid = _authService.ValidateKeyAsync(apiKey).Result; 

    if (!isValid)
    {
        context.Response.StatusCode = 401;
        return;
    }

    await _next(context);
}

Using .Result or .Wait() in middleware causes thread starvation. Under heavy load, the thread pool runs out of threads, and the server stops responding. Always await your async calls.

2. Swallowing Exceptions and Breaking the Chain

When an exception occurs in your application, you want the default UseExceptionHandler or your custom logging middleware to catch it. But I frequently see custom try/catch blocks in middleware that swallow the error and return a generic 500 without logging the stack trace.

Worse, I see developers forgetting to call await _next(context) on the happy path, essentially 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!
        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: If you need a global exception handler, don't write a custom middleware for it. Just use the built-in IExceptionHandler interface introduced in .NET 8. It’s cleaner, safer, and integrated directly into the framework.

3. Capturing Scoped Services in a Singleton

This is the most subtle, yet destructive bug you can introduce.

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 EF 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 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
        // 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 for Singletons only.

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 use the IMiddleware interface, which forces the middleware itself to be registered as a Scoped dependency, sidestepping the issue entirely.

Final Word

Writing custom middleware gives you God-like power over the HTTP pipeline. But with that power comes the responsibility of not tanking your server's throughput. Keep it async, inject dependencies correctly, and if you can accomplish your goal using an Action Filter or an Endpoint Filter instead, you probably should.

About the Author

Ajay Patel

Software Developer specializing in .NET, C#, and ASP.NET Core. Built .NET Toolbox to give developers privacy-first, browser-based utilities for their daily workflow.

Read more about Ajay