Middleware Pipeline
Key Points
- The ASP.NET Core middleware pipeline is a chain of
Func<HttpContext, RequestDelegate, Task>delegates. Each can short-circuit, inspect, or modify the request and response. - Order matters.
UseRouting→ auth →UseEndpointsis the canonical ordering. Auth must be after routing (it needs to know the matched endpoint's policy) and before endpoint execution. - Three flavors of middleware:
- Inline via
app.Use(async (ctx, next) => ...). - Convention-based class with
Invoke/InvokeAsync(legacy but still common). IMiddlewareinterface — DI-friendly, scoped per-request.Map,MapWhen,UseWhenbranch the pipeline.- Terminal middleware (
Run) doesn't callnext— short-circuits. - Common ordering error: putting
app.UseAuthentication()beforeapp.UseRouting()— auth doesn't know which endpoint is matched, attribute-based authorization fails.
Concepts (deep dive)
The pipeline
incoming request
│
▼
┌─────────────────────────────────────────────┐
│ Middleware 1 │
│ ┌─────────────────────────────────────────┤
│ │ Middleware 2 │
│ │ ┌──────────────────────────────────────┤
│ │ │ Middleware 3 │
│ │ │ ┌───────────────────────────────────┤
│ │ │ │ Endpoint (controller / Map / ...) │
│ │ │ └───────────────────────────────────┤
│ │ └──────────────────────────────────────┤
│ └─────────────────────────────────────────┤
└─────────────────────────────────────────────┘
│
▼
outgoing response
Each middleware: 1. Optionally inspects/mutates the request. 2. Calls await next(ctx) to invoke the next middleware. 3. Optionally inspects/mutates the response. 4. Returns.
If a middleware doesn't call next, the pipeline short-circuits — useful for caching, auth rejections, etc.
Canonical ordering
var app = builder.Build();
app.UseExceptionHandler("/error"); // first: catch unhandled exceptions
app.UseHsts();
app.UseHttpsRedirection();
app.UseStaticFiles(); // serve static assets early; short-circuits
app.UseRouting(); // identifies the endpoint
app.UseCors(); // after routing, before auth
app.UseAuthentication(); // identifies the user
app.UseAuthorization(); // verifies user permitted for endpoint
app.UseRateLimiter();
app.MapControllers(); // endpoint execution
app.MapGet("/", () => "OK");
await app.RunAsync();
Why this order:
UseExceptionHandlermust be first — catches everything below.UseRoutingbefore auth —UseAuthorizationneeds the matched endpoint to read[Authorize]attributes.UseAuthenticationbeforeUseAuthorization.UseEndpoints(or its modernMap*shorthand) is implicit at the end.
⚠️ Most common error:
app.UseAuthorization()beforeapp.UseRouting(). The[Authorize]attribute is on the endpoint; withoutUseRoutinghaving matched,UseAuthorizationdoesn't see the policy.
Inline middleware
app.Use(async (ctx, next) =>
{
var sw = Stopwatch.StartNew();
await next(ctx);
var ms = sw.ElapsedMilliseconds;
if (ms > 500) Logger.LogWarning("Slow request {Path} took {Ms}ms", ctx.Request.Path, ms);
});
The simplest form. Good for one-off logic. Captures locals (closure overhead).
Convention-based middleware (class)
public class TimingMiddleware
{
private readonly RequestDelegate _next;
public TimingMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext ctx)
{
var sw = Stopwatch.StartNew();
await _next(ctx);
Logger.LogInformation("{Path} {Ms}ms", ctx.Request.Path, sw.ElapsedMilliseconds);
}
}
app.UseMiddleware<TimingMiddleware>();
Convention: constructor accepts RequestDelegate next + any singleton services. InvokeAsync runs once per request. Other constructor-injected services are captured at startup — must be singletons.
To inject scoped services into InvokeAsync directly:
public async Task InvokeAsync(HttpContext ctx, ITenantContext tenant) // Tenant is scoped
{
/* ... */
await _next(ctx);
}
The runtime resolves additional InvokeAsync parameters from the request scope.
IMiddleware interface
public class TimingMiddleware : IMiddleware
{
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
{
var sw = Stopwatch.StartNew();
await next(ctx);
Logger.LogInformation("{Path} {Ms}ms", ctx.Request.Path, sw.ElapsedMilliseconds);
}
}
builder.Services.AddTransient<TimingMiddleware>(); // or scoped
app.UseMiddleware<TimingMiddleware>();
IMiddleware is DI-resolved per request — easier to test and mock. Prefer over convention-based for new code.
Use, Run, Map, MapWhen, UseWhen
app.Use(async (ctx, next) =>
{
// before
await next(ctx);
// after
});
app.Run(async ctx =>
{
// terminal — no next; nothing after this runs
await ctx.Response.WriteAsync("Hello");
});
app.Map("/admin", admin =>
{
admin.Use(/* admin-only middleware */);
admin.MapGet("/", () => "Admin home");
});
app.MapWhen(ctx => ctx.Request.Headers.ContainsKey("X-Special"),
branch => branch.UseSpecialMiddleware());
app.UseWhen(ctx => ctx.Request.Path.StartsWithSegments("/api"),
branch => branch.UseRateLimiter());
Mapbranches based on path prefix; the prefix is stripped fromRequestPath.MapWhenbranches on a predicate; doesn't strip path.UseWhenlikeMapWhenbut the branch rejoins the main pipeline if it doesn't terminate. Good for conditional cross-cutting concerns.
Sharing state via HttpContext.Items
app.Use(async (ctx, next) =>
{
ctx.Items["RequestId"] = Guid.NewGuid().ToString();
await next(ctx);
});
// Later in pipeline / endpoint:
var id = ctx.Items["RequestId"] as string;
Per-request key/value bag. Don't put large objects in there; everything in Items is held for the request's lifetime.
Short-circuiting
app.Use(async (ctx, next) =>
{
if (ctx.Request.Headers.UserAgent == "BadBot")
{
ctx.Response.StatusCode = 403;
await ctx.Response.WriteAsync("denied");
return; // don't call next; terminate
}
await next(ctx);
});
If you don't call next, the rest of the pipeline doesn't run. Used by caching middleware (return cached body), auth rejections, rate limiters.
Built-in middleware reference
| Middleware | Purpose |
|---|---|
UseExceptionHandler | Catches unhandled exceptions; produces ProblemDetails |
UseDeveloperExceptionPage | Dev-only detailed error page |
UseHsts | HSTS header |
UseHttpsRedirection | Redirect HTTP → HTTPS |
UseStaticFiles | Serve files from wwwroot |
UseRouting | Match endpoints |
UseCors | CORS |
UseAuthentication / UseAuthorization | Auth |
UseRateLimiter | Rate limit |
UseOutputCache | Output caching |
UseResponseCompression | gzip/brotli |
UseRequestLocalization | Culture from request |
UseSession | Session state |
UseStatusCodePages | Friendly error pages |
Custom middleware lifecycle
public class TimingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<TimingMiddleware> _log;
// ✅ Constructor singletons captured at startup
public TimingMiddleware(RequestDelegate next, ILogger<TimingMiddleware> log)
{
_next = next; _log = log;
}
// ✅ Per-request: inject scoped services here
public async Task InvokeAsync(HttpContext ctx, ITenantContext tenant)
{
using (_log.BeginScope("Tenant: {Tenant}", tenant.Id))
{
await _next(ctx);
}
}
}
Convention-based middleware is itself a singleton; only its InvokeAsync parameters can be scoped. IMiddleware is resolved per request — both the middleware and its dependencies can be scoped.
Code: correct vs wrong
❌ Wrong: UseAuthorization before UseRouting
app.UseAuthorization();
app.UseRouting();
app.MapControllers(); // [Authorize] attributes don't apply
✅ Correct order
❌ Wrong: scoped service in convention-based middleware constructor
public TimingMiddleware(RequestDelegate next, MyDbContext db) // ❌ db is scoped
{
/* container error at startup */
}
✅ Correct: inject in InvokeAsync
public async Task InvokeAsync(HttpContext ctx, MyDbContext db)
{
/* db is the request-scoped instance */
}
❌ Wrong: not calling await next
app.Use(async (ctx, next) =>
{
Log("hit");
// forgot to call next!
// pipeline silently doesn't continue
});
✅ Correct
❌ Wrong: writing to response after next
app.Use(async (ctx, next) =>
{
await next(ctx);
await ctx.Response.WriteAsync("appendix"); // ❌ if downstream wrote, response started; this throws
});
✅ Correct: check HasStarted
app.Use(async (ctx, next) =>
{
await next(ctx);
if (!ctx.Response.HasStarted)
await ctx.Response.WriteAsync("appendix");
});
Design patterns for this topic
Pattern 1 — "IMiddleware for DI-heavy middleware"
- Intent: clean per-request DI; testable.
Pattern 2 — "Branching with Map/MapWhen/UseWhen"
- Intent: apply middleware only on certain paths/conditions.
Pattern 3 — "Short-circuit for caching"
- Intent: return cached response without invoking endpoint.
Pattern 4 — "Endpoint-aware middleware"
- Intent: run logic based on the matched endpoint metadata (e.g., a custom
[FeatureFlag("X")]attribute). - Code sketch:
app.Use(async (ctx, next) =>
{
var endpoint = ctx.GetEndpoint();
var meta = endpoint?.Metadata.GetMetadata<FeatureFlagAttribute>();
if (meta?.Flag is string flag && !flagSvc.IsEnabled(flag))
{
ctx.Response.StatusCode = 404;
return;
}
await next(ctx);
});
Pattern 5 — "ProblemDetails-producing exception handler"
- Intent: unified error response.
- Code sketch: see ProblemDetails & Error Handling.
Pros & cons / trade-offs
| Approach | Pros | Cons |
|---|---|---|
Inline Use | Simple | Closure capture; harder to test |
| Convention-based class | Standard | Singleton; scoped via Invoke only |
IMiddleware | Per-request DI | Slightly more setup |
Map | Clean path scoping | Strips path |
UseWhen | Conditional without branch | Subtle behavior |
When to use / when to avoid
- Use
IMiddlewarefor new components needing scoped DI. - Use inline
Usefor tiny, one-off middleware. - Use
MapWhen/UseWhenfor path/predicate-scoped behavior. - Avoid wrong ordering — auth before routing is a top bug.
- Avoid heavy work without short-circuit — adds latency to every request.
Interview Q&A
Q1. What's the canonical middleware order? ExceptionHandler → HSTS → HttpsRedirect → StaticFiles → Routing → CORS → Authentication → Authorization → RateLimit → Endpoints.
Q2. Difference between Map and MapWhen? Map(prefix, branch) matches by path prefix and strips it. MapWhen(predicate, branch) matches by predicate and doesn't strip path.
Q3. What's UseWhen vs MapWhen? UseWhen lets the branch rejoin the main pipeline when not terminal. MapWhen is a hard branch.
Q4. Why must UseAuthorization come after UseRouting? Authorization reads [Authorize] attributes from the matched endpoint's metadata. Without UseRouting, no endpoint is matched yet.
Q5. Difference between convention-based middleware and IMiddleware? Convention is singleton with Invoke/InvokeAsync; scoped DI works only via InvokeAsync parameters. IMiddleware is resolved per request; both itself and its deps can be scoped.
Q6. What's HttpContext.Items for? A per-request key/value bag for sharing state between middleware and endpoint without typed services.
Q7. How do you short-circuit? Don't call next. Set ctx.Response.StatusCode and write the response body.
Q8. What does ctx.Response.HasStarted mean? The first byte of the response has been flushed to the wire. After that, you can't change status code or headers; writing to Body may still work depending on transport.
Q9. Why might middleware after UseEndpoints not run? Endpoint execution is terminal in the routing-aware pipeline. Anything after MapControllers()/MapGet() only runs if the endpoint didn't terminate (rare).
Q10. What's the cost of middleware? Each is a delegate invocation per request. In a 10-middleware pipeline at 10⁶ rps, the overhead is real but small. Heavy work inside the middleware (logging, DB calls) dominates.
Q11. How do you test middleware? With WebApplicationFactory<TStartup> for integration tests; or by constructing the middleware directly with a fake HttpContext and a next stub.
Q12. Endpoint-aware middleware — how? After UseRouting, ctx.GetEndpoint() is non-null. Read metadata via endpoint.Metadata.GetMetadata<TAttribute>(). Common pattern for feature flags, role-based gating, etc.
Gotchas / common mistakes
- ⚠️ Wrong ordering — auth before routing.
- ⚠️ Forgetting
await next— pipeline truncates silently. - ⚠️ Writing after
HasStarted— InvalidOperationException. - ⚠️ Scoped service in convention-based ctor — DI error.
- ⚠️
Runinstead ofUse— terminal; nothing after runs. - ⚠️ Heavy work without scope — bottleneck per request.