.NET Academy

The middleware pipeline

How ASP.NET Core processes every request — built-in middleware, ordering, and writing your own.

13 min read

You will learn

  • Explain how middleware is composed into a pipeline
  • Order built-in middleware correctly
  • Write custom middleware inline and as a class
  • Build a pipeline from delegates yourself

Every request takes the same path

In ASP.NET Core, every request flows through a pipeline of middleware components configured in Program.cs. Each one can inspect the request, call the next middleware, and act on the response as it comes back — or stop the request right there.

Step through real scenarios — a normal API call, a missing token, a static file, plain HTTP, and an exception:

Interactive

ASP.NET Core middleware pipeline

Follow a request in through each middleware and the response back out

1/10
GET https://shop.example/api/orders
Authorization: Bearer eyJhbGci…
  1. 1Exception handler↓ request
  2. 2HTTPS redirection
  3. 3Static files
  4. 4Routing
  5. 5Authentication
  6. 6Authorization
  7. 7Endpoint

Program.cs

var app = builder.Build();
 
app.UseExceptionHandler("/error");
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/api/orders", GetOrders).RequireAuthorization();
 
app.Run();

Middleware runs in the order it's registered. Each one can act, then call next() — or stop the request right there.

The request enters the pipeline. The exception handler wraps everything after it in a try/catch.

A typical pipeline

var app = builder.Build();
 
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/error");   // 1. outermost: catches everything below
    app.UseHsts();
}
 
app.UseHttpsRedirection();               // 2. http → https
app.UseStaticFiles();                    // 3. serve wwwroot files, short-circuit
app.UseRouting();                        // 4. pick the endpoint
app.UseCors("frontend");                 // 5. CORS headers / preflight
app.UseAuthentication();                 // 6. who are you?
app.UseAuthorization();                  // 7. are you allowed?
app.UseRateLimiter();                    // 8. too many requests?
 
app.MapControllers();                    // endpoints
app.MapGet("/health", () => "OK");
 
app.Run();

Order matters: middleware runs in registration order on the way in, reverse order on the way out. Exception handling must be first to catch everything; authentication must precede authorization; static files come early to skip unnecessary work.

Writing your own middleware

Inline, with app.Use:

app.Use(async (context, next) =>
{
    var sw = Stopwatch.StartNew();
    context.Response.Headers["X-Request-Id"] = context.TraceIdentifier;
 
    await next(context);                           // call the rest of the pipeline
 
    app.Logger.LogInformation("{Method} {Path} → {Status} in {Ms} ms",
        context.Request.Method, context.Request.Path, context.Response.StatusCode, sw.ElapsedMilliseconds);
});

As a reusable class (constructor gets the next delegate; InvokeAsync runs per request):

public class MaintenanceMiddleware(RequestDelegate next, IOptions<MaintenanceOptions> options)
{
    public async Task InvokeAsync(HttpContext context)
    {
        if (options.Value.Enabled && !context.Request.Path.StartsWithSegments("/health"))
        {
            context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
            await context.Response.WriteAsync("Down for maintenance");
            return;                                   // short-circuit: don't call next
        }
        await next(context);
    }
}
 
app.UseMiddleware<MaintenanceMiddleware>();

Don't modify the response after it has started

Once the response body starts being sent, headers and the status code can't change. Set headers before calling next, or use context.Response.OnStarting(...).

How the pipeline is built

There's no magic: each middleware is a function that takes the next RequestDelegate and returns a new RequestDelegate wrapping it. ASP.NET Core composes them from the last to the first, so the first registered ends up outermost:

Build([A, B], endpoint)  =  A( B( endpoint ) )
 
request → A-in → B-in → endpoint → B-out → A-out → response

In this lesson's exercise, you'll write that composition yourself — plus a logging middleware and one that short-circuits.

Middleware vs filters

  • Middleware sees every request, knows nothing about controllers or endpoints' parameters. Good for: logging, error handling, headers, CORS, auth, compression.
  • Endpoint/action filters run only for matched endpoints and can see arguments and results. Good for: validation, endpoint-specific logic.

Key takeaways

  • Every request flows through middleware registered in Program.cs, in order — and back in reverse.
  • Middleware can act before/after next, or short-circuit by not calling it.
  • Order matters: exception handler first, authentication before authorization, static files early.
  • The pipeline is just nested functions: each middleware wraps the next RequestDelegate.

Exercise

Recreate ASP.NET Core's pipeline. Build composes middleware around the endpoint so the FIRST middleware in the list runs first. Logging adds "→ <path>" before calling next and "← <status>" after. RequireAdmin short-circuits requests whose path starts with "/admin": it sets status 401 and does NOT call next. Run and RunWith are helpers used by the checks.

Program.cs
Loading editor…

C# runtime not loaded

Hint 1

Build from the end: RequestDelegate app = endpoint; for (int i = middleware.Count - 1; i >= 0; i--) app = middleware[i](app); return app;

Hint 2

Logging: ctx.Log.Add($"→ {ctx.Path}"); await next(ctx); ctx.Log.Add($"← {ctx.StatusCode}");

Hint 3

RequireAdmin: if (ctx.Path.StartsWith("/admin")) { ctx.StatusCode = 401; return; } await next(ctx);

Show solution
Console.WriteLine(await Run("/products"));
Console.WriteLine(await Run("/admin/users"));

RequestDelegate Build(List<Func<RequestDelegate, RequestDelegate>> middleware, RequestDelegate endpoint)
{
    RequestDelegate app = endpoint;
    for (int i = middleware.Count - 1; i >= 0; i--)
    {
        app = middleware[i](app);
    }
    return app;
}

Func<RequestDelegate, RequestDelegate> Logging() => next => async ctx =>
{
    ctx.Log.Add($"→ {ctx.Path}");
    await next(ctx);
    ctx.Log.Add($"← {ctx.StatusCode}");
};

Func<RequestDelegate, RequestDelegate> RequireAdmin() => next => async ctx =>
{
    if (ctx.Path.StartsWith("/admin"))
    {
        ctx.StatusCode = 401;
        return;
    }
    await next(ctx);
};

// ---- helpers used by the checks (don't change) ----
Func<RequestDelegate, RequestDelegate> Tag(string name) => next => async ctx =>
{
    ctx.Log.Add($"{name}-in");
    await next(ctx);
    ctx.Log.Add($"{name}-out");
};

async Task<string> RunWith(List<Func<RequestDelegate, RequestDelegate>> middleware, string path)
{
    var ctx = new Context { Path = path };
    var app = Build(middleware, c => { c.Log.Add("endpoint"); return Task.CompletedTask; });
    await app(ctx);
    return $"{string.Join(" ", ctx.Log)} [{ctx.StatusCode}]";
}

Task<string> Run(string path) => RunWith([Logging(), RequireAdmin()], path);

delegate Task RequestDelegate(Context context);

class Context
{
    public string Path { get; init; } = "/";
    public int StatusCode { get; set; } = 200;
    public List<string> Log { get; } = new();
}

Quiz

4 questions · 3 correct to pass
  1. 1.Why must UseAuthentication come before UseAuthorization?

  2. 2.What happens if a middleware doesn't call next?

  3. 3.Where should the exception-handling middleware go?

  4. 4.What's the difference between app.Use and app.Run?

To complete this lesson

  • ○ Solve the exercise
  • ○ Pass the quiz (3/4 correct)