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:
ASP.NET Core middleware pipeline
Follow a request in through each middleware and the response back out
GET https://shop.example/api/orders Authorization: Bearer eyJhbGci…
- 1Exception handler↓ request
- 2HTTPS redirection
- 3Static files
- 4Routing
- 5Authentication
- 6Authorization
- 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.
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 → responseIn 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.
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 pass1.Why must UseAuthentication come before UseAuthorization?
2.What happens if a middleware doesn't call next?
3.Where should the exception-handling middleware go?
4.What's the difference between app.Use and app.Run?
To complete this lesson
- ○ Solve the exercise
- ○ Pass the quiz (3/4 correct)