Skip to content

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 → UseEndpoints is 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).
  • IMiddleware interface — DI-friendly, scoped per-request.
  • Map, MapWhen, UseWhen branch the pipeline.
  • Terminal middleware (Run) doesn't call next — short-circuits.
  • Common ordering error: putting app.UseAuthentication() before app.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:

  1. UseExceptionHandler must be first — catches everything below.
  2. UseRouting before auth — UseAuthorization needs the matched endpoint to read [Authorize] attributes.
  3. UseAuthentication before UseAuthorization.
  4. UseEndpoints (or its modern Map* shorthand) is implicit at the end.

⚠️ Most common error: app.UseAuthorization() before app.UseRouting(). The [Authorize] attribute is on the endpoint; without UseRouting having matched, UseAuthorization doesn'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());
  • Map branches based on path prefix; the prefix is stripped from RequestPath.
  • MapWhen branches on a predicate; doesn't strip path.
  • UseWhen like MapWhen but 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 HTTPHTTPS
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

app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

❌ 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

app.Use(async (ctx, next) =>
{
    Log("hit");
    await next(ctx);
});

❌ 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"


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 IMiddleware for new components needing scoped DI.
  • Use inline Use for tiny, one-off middleware.
  • Use MapWhen / UseWhen for 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 ctorDI error.
  • ⚠️ Run instead of Use — terminal; nothing after runs.
  • ⚠️ Heavy work without scope — bottleneck per request.

Further reading