Skip to content
Search lessons, topics, tests…
Esc

    ↑ ↓ moveEnter openEsc close

    Guided course · ASP.NET Core

    ASP.NET Core Internals: back to the course

    Module 1 · Hosting and the Request Pipeline · Lesson 2 of 18

    The Request Pipeline: Middleware Order, Short-Circuits and Branches

    Learning outcome

    You can predict the exact order in which middleware runs on the way in and on the way out, explain how Use, Run, Map, MapWhen and UseWhen differ, say why a header cannot be added after the body has started, and order a production pipeline correctly from memory.

    Intuition

    The pipeline is an onion. Each middleware gets the HttpContext and a next delegate. Code before await next(ctx) runs on the way in, code after it runs on the way out, in reverse order. A middleware that does not call next short-circuits: everything after it is skipped, but everything before it still runs its "out" half.

    flowchart LR
        R["request"] --> A["A: in"] --> B["B: in"] --> C["C: in"] --> E["endpoint"]
        E --> C2["C: out"] --> B2["B: out"] --> A2["A: out"] --> S["response"]
        B -. "short-circuit (no next)" .-> B2

    Deep dive

    How the pipeline is built

    app.Use(...) does not run anything. It adds a Func<RequestDelegate, RequestDelegate> to a list. When the host starts, ApplicationBuilder.Build() walks that list backwards, wrapping each component around the next, and ends with a default terminal delegate that returns 404. The result is one RequestDelegate that Kestrel calls per request. That is why registration order is execution order.

    The building blocks

    APICalls next?Rejoins main pipeline?Typical use
    UseOptionalYesMost middleware
    RunNever (terminal)NoFinal handler
    Map("/path", ...)Branch has its own pipelineNoPath-based sub-app
    MapWhen(predicate, ...)Branch has its own pipelineNoCondition-based sub-app
    UseWhen(predicate, ...)Branch can call next or short-circuitYes, unless the branch short-circuitsConditional extra middleware
    MapGet/MapPost...Endpoint (routing)n/aYour API handlers

    The response-started rule

    HTTP sends the status line and headers before the body. Once the first body byte is written, Response.HasStarted becomes true and headers and status code are frozen. Setting them later throws InvalidOperationException. To add a header "at the end", register a callback with Response.OnStarting(...), which runs just before the headers are sent.

    A production order worth memorising

    The order below follows the Microsoft guidance for an API with authentication:

    1. UseExceptionHandler (or the developer exception page in Development): first, so it wraps everything.
    2. UseHsts, then UseHttpsRedirection.
    3. UseStaticFiles (if any): early, so files skip auth and routing.
    4. UseRouting: selects the endpoint.
    5. UseCors: after routing so endpoint CORS policies are visible, before auth.
    6. UseAuthentication, then UseAuthorization.
    7. UseRateLimiter, UseOutputCache, custom middleware that needs the endpoint or the user.
    8. Endpoints (MapControllers, MapGet...).

    The free TIN ASP.NET Core Middleware Order Cheat Sheet has the full table; this lesson explains why the order is what it is.

    Code example: trace the onion

    C#
    // L02: middleware is an onion. Order of registration = order of execution on the way in, reversed on the way out.
    var builder = WebApplication.CreateBuilder(args);
    builder.Logging.ClearProviders();
    builder.WebHost.UseUrls("http://127.0.0.1:0");
    var app = builder.Build();
    var log = new List<string>();
    
    app.Use(async (ctx, next) =>                         // A: wraps everything after it
    {
        log.Add("A in");
        await next(ctx);
        log.Add($"A out (status {ctx.Response.StatusCode})");
    });
    
    app.Use(async (ctx, next) =>                         // B: short-circuits /blocked
    {
        if (ctx.Request.Path.StartsWithSegments("/blocked"))
        {
            log.Add("B short-circuit 403");
            ctx.Response.StatusCode = 403;
            return;                                      // next() never called: C and the endpoint never run
        }
        log.Add("B in");
        await next(ctx);
        log.Add("B out");
    });
    
    app.UseWhen(ctx => ctx.Request.Query.ContainsKey("trace"),   // branch that REJOINS the main pipeline
        branch => branch.Use(async (ctx, next) => { log.Add("UseWhen branch"); await next(ctx); }));
    
    app.Map("/health", branch => branch.Run(async ctx =>         // branch that does NOT rejoin
    {
        log.Add("Map(/health) terminal");
        await ctx.Response.WriteAsync("healthy");
    }));
    
    app.Use(async (ctx, next) =>                         // C: tries to add a header AFTER the body was written
    {
        log.Add("C in");
        await next(ctx);
        try { ctx.Response.Headers["X-Too-Late"] = "1"; log.Add("C out: header added"); }
        catch (InvalidOperationException) { log.Add($"C out: header rejected, HasStarted={ctx.Response.HasStarted}"); }
    });
    
    app.MapGet("/hello", () => { log.Add("endpoint /hello"); return "hi"; });
    
    await app.StartAsync();
    using var http = new HttpClient { BaseAddress = new Uri(app.Urls.First()) };
    foreach (var path in new[] { "/hello", "/blocked", "/health", "/hello?trace=1" })
    {
        log.Clear();
        var res = await http.GetAsync(path);
        await Task.Delay(100);                           // let "out" steps finish after the client has the response
        Console.WriteLine($"GET {path} -> {(int)res.StatusCode}");
        Console.WriteLine("   " + string.Join(" | ", log));
    }
    await app.StopAsync();

    Output

    Text
    GET /hello -> 200
       A in | B in | C in | endpoint /hello | C out: header rejected, HasStarted=True | B out | A out (status 200)
    GET /blocked -> 403
       A in | B short-circuit 403 | A out (status 403)
    GET /health -> 200
       A in | B in | Map(/health) terminal | B out | A out (status 200)
    GET /hello?trace=1 -> 200
       A in | B in | UseWhen branch | C in | endpoint /hello | C out: header rejected, HasStarted=True | B out | A out (status 200)

    Code walkthrough

    • /hello: A, B, C in; endpoint; C, B, A out. The reverse order on the way out is the onion.
    • C tried to add a header after next returned. The endpoint had already written "hi", so HasStarted=True and the header was rejected. Use OnStarting instead (Practice set, E10).
    • /blocked: B returned without calling next. C and the endpoint never ran, but A still ran its "out" half and saw status 403.
    • /health: Map created a separate branch with its own terminal Run. C never ran: a Map branch does not rejoin the main pipeline.
    • /hello?trace=1: UseWhen ran its extra middleware and then rejoined: C and the endpoint still ran.

    Summary table

    BehaviourRule
    Execution orderRegistration order on the way in, reverse on the way out
    Short-circuitDo not call next; earlier middleware still runs its out half
    Map/MapWhenSeparate branch; does not come back
    UseWhenConditional detour; rejoins unless the branch short-circuits
    Headers after body startedThrows; use Response.OnStarting
    No endpoint matchedPipeline falls through to the terminal 404

    Failure modes

    • Exception handler registered late: exceptions thrown by middleware before it are not caught (Practice set, E4).
    • Authorization before authentication: policies relying on the default-scheme user can see an anonymous principal and return 401 (Practice set, E1). Policies with explicit authentication schemes can authenticate during authorization; keep the recommended order.
    • CORS after authorization: preflight requests are rejected before the CORS policy can answer them.
    • Writing to the response and then calling next: later middleware cannot change headers or status.
    • Using Map when you meant UseWhen: the rest of your pipeline silently stops running for that path.

    Interview angle

    "Why does order matter, and what is your middleware order?" Say that the pipeline is built once, backwards, into a single delegate, so order is execution order. Then give the order with a reason for each step: exception handler first because it must wrap everything; routing before CORS and auth because they read endpoint metadata; authentication before authorization because authorization needs the user. Finish with the HasStarted rule. Interviewers like candidates who know why headers cannot change after the body starts.

    Practice questions

    1. Middleware A logs before and after next; B short-circuits. Which lines does A log?

    Answer: Both. A's "in" runs, B returns without calling next, then A's "out" runs with B's status code.

    1. What is the difference between app.Map("/admin", ...) and app.UseWhen(ctx => ctx.Request.Path.StartsWithSegments("/admin"), ...)?

    Answer: Map creates a branch that never returns to the main pipeline (and strips the matched path prefix into PathBase). UseWhen runs extra middleware and continues down the main pipeline unless its branch short-circuits.

    1. How do you add a response header that contains the time until headers are sent?

    Answer: Start a stopwatch and register Response.OnStarting to set the header just before headers are sent. This is not total processing time for a streaming response; record completion duration separately in logs or metrics. Setting the header after await next() fails if the body has started.

    1. Where does UseCors go and why?

    Answer: After UseRouting (so endpoint-specific CORS metadata is visible) and before UseAuthentication/UseAuthorization (so preflight requests are answered before auth rejects them).

    Revision checklist

    • Pipeline = list of Func<RequestDelegate, RequestDelegate>, composed backwards into one delegate.
    • In-order on the way in, reverse on the way out.
    • Run is terminal; Map/MapWhen branch away; UseWhen rejoins unless its branch short-circuits.
    • Headers freeze when the body starts: OnStarting.
    • Exception handler first; routing, CORS, authentication, authorization, then endpoint-aware middleware.

    Analogy

    Everyday picture

    Airport security lanes

    Each checkpoint (middleware) inspects you on the way to the gate and can send you back (short-circuit). A Map branch is a separate terminal: once you are sent there, you never return to the main hall. UseWhen is the extra bag check: you go through it and then rejoin the same queue. And once you have boarded (the body has started), nobody can change your boarding pass (headers).

    Quick reference

    Middleware rules

    • Order of registration = order of execution in; reverse out.
    • No next call = short-circuit.
    • Map branches away; UseWhen comes back unless its branch short-circuits.
    • Response.HasStarted true = headers and status frozen; use OnStarting.
    • Exception handler, HSTS/HTTPS, static files, routing, CORS, authentication, authorization, rate limiting/output cache, endpoints.
    Sign in to mark lessons done and keep your place in the course.Sign in