← Back to list

The Middleware Pipeline Is Not Magic

How ASP.NET Core actually wires up your requests, and how to build your own middleware from scratch

Kenji Elzerman · 2026-05-26 05:01 · 0 claps · 4.8 min read paywalled
#dotnet #aspnetcore #csharp #software-development #web-development
Open on Medium ↗
Wiki topics: 🌐 · Web Development

The Middleware Pipeline Is Not Magic

Every request your ASP.NET Core app receives passes through a chain of components before it ever hits your controller. Most developers know this. Not many can explain what that chain actually looks like under the hood or why the order in which you register middleware matters so much.

I used to just call app.UseAuthentication() and app.UseAuthorization() and trust that it worked. That’s fine until something breaks. Then you need to actually understand what’s going on.

Let’s build the pipeline from scratch.

What the Pipeline Actually Is

ASP.NET Core’s request pipeline is a linked list of delegates. Each middleware is a function that takes an HttpContext and a reference to the next delegate in the chain. It can do work before calling next, call next to pass control forward, do more work after next returns, or skip next entirely and short-circuit the whole chain.

Microsoft’s own documentation describes it as a chain of request delegates, and that description is accurate. The key thing to understand is that calling next() does not hand off control and walk away. It suspends the current middleware while the rest of the chain runs, then returns. This is why middleware can run code both before and after the downstream work.

Here is the simplest possible picture of it

If any middleware does not call next, everything after it never runs. That is how authentication works: if the token is invalid, it short-circuits and returns a 401 without ever touching your controller.

The RequestDelegate Under the Hood

At its core, middleware is just a RequestDelegate:

public delegate Task RequestDelegate(HttpContext context);

That’s it. A function that takes an HttpContext and returns a Task. When you call app.Use() adds a function to the pipeline that accepts the current context and the next delegate.

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
WebApplication app = builder.Build();

app.Use(async (HttpContext context, RequestDelegate next) =>
{
    Console.WriteLine("Before");
    await next(context);
    Console.WriteLine("After");
});

app.MapGet("/", () => "Hello");
app.Run();

Run this and hit the endpoint. You will see “Before”, then the response, then “After” in your console. The request goes in, the delegate fires, next is awaited, and the response comes back out. That’s the whole model.

Now let’s build something real.

Building Middleware That Does Something Useful

A timing middleware is a classic example because it’s simple, practical, and clearly illustrates the before/after model.

public class TimingMiddleware
{
    private readonly RequestDelegate _next;

    public TimingMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        System.Diagnostics.Stopwatch stopwatch = System.Diagnostics.Stopwatch.StartNew();

        await _next(context);

        stopwatch.Stop();

        long elapsed = stopwatch.ElapsedMilliseconds;
        Console.WriteLine($"[{context.Request.Method}] {context.Request.Path} took {elapsed}ms");
    }
}

Register it with an extension method so it reads cleanly in Program.cs:

public static class TimingMiddlewareExtensions
{
    public static IApplicationBuilder UseRequestTiming(this IApplicationBuilder app)
    {
        return app.UseMiddleware<TimingMiddleware>();
    }
}

Then in Program.cs:

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
WebApplication app = builder.Build();

app.UseRequestTiming();

app.MapGet("/products", () =>
{
    List<Product> products =
    [
        new() { Id = 1, Title = "7Up", Status = Status.Ordered, Stock = 10, Available = true },
        new() { Id = 2, Title = "Chips", Status = Status.Ordered, Stock = 0, Available = true },
        new() { Id = 3, Title = "Sugar", Status = Status.Delivered, Stock = 67, Available = true },
    ];

    return Results.Ok(products);
});

app.Run();

Every request to /products will now log the method, path, and the time taken. That is genuinely useful output, and it cost you maybe 20 lines of code.

Order Matters… A Lot

This is where most people get burned. Middleware runs in the order you register it, and that order has real consequences.

app.UseAuthentication();   // must come before authorization
app.UseAuthorization();    // needs authentication to have already run
app.UseRateLimiter();      // should come after auth so you can rate-limit per user

If you swap UseAuthentication and UseAuthorization, your app will still compile. It will just silently fail in ways that are annoying to debug. Authorization runs before the identity is set, so it has nothing to check against.

The same is true for your custom middleware. If you put UseRequestTiming after UseRouting, your timings will include routing overhead. Put it before, and they won’t. Neither is wrong exactly, but you need to know what you are measuring.

A mental model that helps: think of middleware as Russian dolls. The first one you register is the outermost doll. It wraps everything. The last one you register is the innermost. Requests travel inward. Responses travel back outward.

Short-Circuiting on Purpose

Sometimes you want middleware to stop the chain entirely. An API key check is a good example:

public class ApiKeyMiddleware
{
    private readonly RequestDelegate _next;
    private const string ApiKeyHeader = "X-Api-Key";
    private const string ValidKey = "super-secret-123";

    public ApiKeyMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        bool hasKey = context.Request.Headers.TryGetValue(ApiKeyHeader, out Microsoft.Extensions.Primitives.StringValues extractedKey);

        if (!hasKey || extractedKey != ValidKey)
        {
            context.Response.StatusCode = 401;
            await context.Response.WriteAsync("Invalid or missing API key.");
            return;
        }

        await _next(context);
    }
}

Notice the return after writing the 401. We never call _next, so nothing downstream runs at all. The request is dead right there.

This is exactly how the built-in authentication middleware works. It checks the token, and if something is wrong, it stops the chain before your controllers ever see the request.

If you want to go deeper on building custom middleware for your own projects, I have a practical walkthrough on kenslearningcurve.com that covers a few more real-world examples.

What About app.Run vs app.Use?

You might have seen both in tutorials and wondered what the difference between them is.

app.Use adds middleware to the pipeline and lets you call next. app.Run adds a terminal middleware that never calls next. It ends the pipeline.

app.Use(async (HttpContext context, RequestDelegate next) =>
{
    Console.WriteLine("I run and pass on");
    await next(context);
});

app.Run(async (HttpContext context) =>
{
    await context.Response.WriteAsync("I am the end of the line");
});

If you put app.Run in the middle of your pipeline, everything registered after it is dead code. It never runs. This is a surprisingly easy mistake to make when you are prototyping and copying code from different tutorials.

The Pipeline Is Just Functions

That’s the whole thing. There is no magic framework glue here. ASP.NET Core builds up a chain of delegates at startup, and when a request arrives, it calls the first one in the chain.

Understanding this makes debugging so much easier. When a request mysteriously returns a 403 before reaching your controller, you know to look at the middleware registered before your endpoint routing, not inside the controller itself.

It also makes writing your own middleware feel less scary. You are not extending some complex base class or implementing a big interface. You need a constructor that takes a RequestDelegate, and an InvokeAsync method. That’s the whole contract.

Most .NET developers use middleware every single day without knowing what it actually is. Now you do.

Disclaimer: All example code has been tested by me in Visual Studio 2026, .NET 10, and C# 14. I use Grammarly for spellchecking. I am not affiliated with Microsoft.


메타데이터
post_id
37623a2dfebe
slug
the-middleware-pipeline-is-not-magic-37623a2dfebe
url
https://medium.com/@kenslearningcurve/the-middleware-pipeline-is-not-magic-37623a2dfebe
canonical_url
https://medium.com/@kenslearningcurve/the-middleware-pipeline-is-not-magic-37623a2dfebe
author_url
https://medium.com/@kenslearningcurve
status
ok
fetched_at
2026-06-09 15:37:30