Every request passes through Kestrel (the built-in, cross-platform web server) and then a chain of middleware — each piece doing one job and either handling the request itself or passing it to the next:
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
Order matters, always. A request travels down this list top-to-bottom; a response travels back up the same list in reverse. UseAuthorization() before UseAuthentication() would try to check permissions for a request that hasn't identified who's making it yet — this fails in a way that's confusing to debug if you don't already know the pipeline runs top-to-bottom on the way in. UseCors() needs to run before anything that might short-circuit the request (like authorization rejecting it) for the CORS headers to actually reach a rejected response.
Custom middleware is just a function of HttpContext and a "next" delegate:
app.Use(async (context, next) =>
{
var stopwatch = Stopwatch.StartNew();
await next(context); // pass control down the chain — this call is what makes it a pipeline, not a dead end
stopwatch.Stop();
Console.WriteLine($"{context.Request.Path} took {stopwatch.ElapsedMilliseconds}ms");
});
Forgetting to call next(context) silently ends the pipeline right there — every middleware registered after it never runs, and (unless this middleware writes its own response) the client gets an empty or hung response. This is the same "chain of responsibility" shape used across most web frameworks — see Middleware Pipelines Compared for how Express.js and Django implement the identical idea with different syntax.
app.Use vs. app.Run vs. app.Map: Use adds a pipeline component that can call next to continue; Run adds a terminal component that never calls anything after it (used for the very last middleware in a branch); Map/MapWhen branches the pipeline based on the request path or a condition, building an entirely separate sub-pipeline for matched requests.