Middleware Pipeline Fundamentals — Request and Response Flow

The middleware pipeline is ASP.NET Core’s request processing mechanism. When an HTTP request arrives, it passes through a sequence of middleware components — each component can inspect, modify, or short-circuit the request, and can do work both before and after the next component in the chain. The pipeline is configured in Program.cs and runs for every request. Understanding the pipeline model is essential for every aspect of ASP.NET Core: authentication, CORS, logging, exception handling, and request routing all operate as middleware.

The Pipeline Model

// ── The pipeline as a chain of delegates ──────────────────────────────────
// Request flows in → through each middleware → to the endpoint
// Response flows out ← back through each middleware ← from the endpoint

// app.Use — passes to the next middleware (most middleware)
app.Use(async (context, next) =>
{
    // Before — runs when request arrives
    Console.WriteLine($"[1] Request: {context.Request.Method} {context.Request.Path}");

    await next(context);   // call the next middleware

    // After — runs when response returns (unwinding the chain)
    Console.WriteLine($"[1] Response: {context.Response.StatusCode}");
});

app.Use(async (context, next) =>
{
    Console.WriteLine("[2] Before");
    await next(context);
    Console.WriteLine("[2] After");
});

// app.Run — terminal middleware; does NOT call next
app.Run(async context =>
{
    Console.WriteLine("[3] Terminal — writing response");
    await context.Response.WriteAsync("Hello from the pipeline!");
});

// Request flow: [1] Before → [2] Before → [3] Terminal
// Response flow: [2] After → [1] After
Note: Each app.Use() call adds a component to the pipeline in order. The components are executed in registration order for requests (top-down) and in reverse order for responses (bottom-up), creating a classic chain-of-responsibility stack. This bidirectional flow is what allows middleware to do pre-request work (authentication, logging the request) and post-response work (logging the response status code, adding response headers, compressing the response body) in a single component.
Tip: The mandatory middleware order in a typical ASP.NET Core Web API is: ExceptionHandler → HSTS → HttpsRedirection → StaticFiles → Routing → CORS → Authentication → Authorization → Custom → MapControllers. Remember it with the mnemonic “Every Hound Hunts Safely, Routing Cats And Animals Carefully, Maps”. Getting the order wrong causes security vulnerabilities (auth after routing can allow unauthenticated access to routes) and functional bugs (CORS headers not sent because CORS middleware runs after the request short-circuits).
Warning: After calling await next(context), the response may already be partially sent to the client (headers flushed). Attempting to modify context.Response.Headers or context.Response.StatusCode after the response has started throws InvalidOperationException: Cannot set status code after response headers have been sent. Always modify response headers before calling next(), or use context.Response.OnStarting() to register a callback that runs just before headers are sent.

Use, Run and Map

// ── app.Use — adds middleware that calls next ─────────────────────────────
app.Use(async (context, next) =>
{
    context.Response.Headers["X-Custom-Header"] = "hello";
    await next(context);
});

// ── app.Run — terminal middleware, never calls next ───────────────────────
app.Run(async context =>
{
    await context.Response.WriteAsync("Terminal response");
    // next is never called — pipeline ends here
});

// ── app.Map — branch pipeline for a specific path prefix ──────────────────
app.Map("/admin", adminApp =>
{
    adminApp.Use(async (ctx, next) =>
    {
        // Only runs for /admin/* requests
        if (!ctx.User.IsInRole("Admin"))
        {
            ctx.Response.StatusCode = 403;
            return;   // short-circuit — do not call next
        }
        await next(ctx);
    });

    adminApp.Run(async ctx =>
        await ctx.Response.WriteAsync("Admin area"));
});

Short-Circuiting the Pipeline

// Short-circuiting: return without calling next to stop request processing
app.Use(async (context, next) =>
{
    // Rate limit check — short-circuit if limit exceeded
    if (IsRateLimitExceeded(context))
    {
        context.Response.StatusCode = 429;   // Too Many Requests
        context.Response.Headers["Retry-After"] = "60";
        await context.Response.WriteAsync("Rate limit exceeded. Retry after 60 seconds.");
        return;   // does NOT call next — pipeline stops here
    }
    await next(context);   // within limit — continue to next middleware
});

Common Mistakes

Mistake 1 — Modifying response headers after calling next (headers already sent)

❌ Wrong — headers may already be flushed after await next:

await next(context);
context.Response.Headers["X-Header"] = "value";  // may throw!

✅ Correct — set headers BEFORE calling next, or use OnStarting callback.

Mistake 2 — Calling next() after short-circuiting (double response)

❌ Wrong — starts writing response, then calls next which also writes:

await context.Response.WriteAsync("Error");
await next(context);   // next also writes — corrupted response!

✅ Correct — always return immediately after short-circuiting.

🧠 Test Yourself

Middleware A is registered before Middleware B. The request arrives. In what order do the “Before” and “After” sections of each middleware execute?