PART 2: The Request Pipeline
PART 1: Creating the ASP.NET Core Web Application
The very first step in every ASP.NET Core application is creating the application builder. In a previous article, we explored this step in detail. As the name suggests, the builder’s role is to assemble all the pieces needed for a runnable application. But the real action begins after the builder does its job. The application goes through two more critical steps in its lifetime: building the application, which finalizes the middleware pipeline and service container, and running it, which starts the web server and begins handling requests. Understanding these stages is key to seeing how an ASP.NET Core app truly comes to life.
Effects of Calling Build()
When you call Build on a web application builder object, a big milestone in the life of the application is reached. At this point, the dependency injection container and the builder configuration are finalized—in other words, you can no longer add new services or edit the components that participate to the configuration tree.
The method Build also sets up the internal structures needed for the middleware pipeline, defines a placeholder for endpoints and returns the WebApplication instance that you’ll actually work with from here on. The returned WebApplication object can then undergo two types of further treatment:
- Configure the request pipeline: You can provide additional instructions for how incoming requests should be handled by adding middleware via app.Use(…) or by mapping endpoints with MapGet, MapPost, or MapControllers.
- Run the application: You can start the application, which activates the web server and begins listening for incoming HTTP requests.
In a way, the method Build marks the transition from assembly mode to executionmode: services are locked in, but you still have the flexibility to shape the request pipeline until the app actually starts running.
All in all, the most important effect of calling Build is the preparation of the structure of the request pipeline. At this stage, the request pipeline exists but it is not finalized yet. ou can still add middleware and endpoints after Build. It’s only when you call Run that ASP.NET Core compilesthe final request pipeline. From that moment on, the application starts listening for incoming HTTP requests, and the pipeline becomes immutable.
The Request Pipeline
The request pipeline is the backbone of how ASP.NET Core handles HTTP traffic. Technically, it is represented as a single, global RequestDelegate object defined as below:
public delegate Task RequestDelegate(HttpContext context);
This delegate is what Kestrel (or any configured server) invokes for every incoming HTTP request. It executes asynchronously, taking the HttpContext as its sole input and eventually producing a response.
The pipeline returns a Task because ASP.NET Core is built around asynchronous, non-blocking request processing. Why Task and not Task<T>? The Task simply indicates the completion of request processing—there’s no actual return value from the delegate. The response is sent by side effect, written directly to the response stream inside the middleware or endpoint.
The pipeline itself is ultimately a chain of RequestDelegate objects where each delegate wraps the next. This allows middleware to process requests before and after the rest of the pipeline, creating a flexible and composable structure that handles all HTTP requests. As a result, all delegates must share the same signature to allow composition of synchronous and asynchronous middleware in a uniform way. If RequestDelegate returned something like Task<T>, the framework would have to handle combining or ignoring the results of each middleware. By using Task alone, the pipeline remains uniform and composable, focusing on operations (e.g., writing to the response) rather than producing values.
Composition of the Middleware
The request pipeline is made up of multiple middleware components, and each middleware is internally wrapped as a RequestDelegate. When you add middleware, here’s what happens:
- The middleware receives a next delegate, representing the rest of the pipeline.
- It can execute code before calling next, which is known as pre-processing.
- It calls await next(context) to pass control to the next middleware in the chain, similar to a set of nested Russian dolls.
- After next completes, it can execute additional code, known as post-processing, before returning.
Here’s a simple example:
app.Use(async (context, next) =>
{
Console.WriteLine("Middleware: Before calling next");
// Call the next middleware in the chain
await next(context);
Console.WriteLine("Middleware: After next completed");
});
The first middleware added to the pipeline is the outermost, while the last one (usually an endpoint) is the innermost, forming a chain through which every request passes.
Creating Custom Middleware
Any piece of middleware can be attached to the request pipeline in one of two equivalent ways: by using the method Use(…) or through a class and a companion extension method. As an example, here’s how to build a middleware that logs the total time taken to process the request into a custom HTTP response header. Let’s tackle the raw Use scenario first.
app.Use(async (context, next) =>
{
var stopwatch = Stopwatch.StartNew();
// Call the next middleware in the chain
await next(context);
stopwatch.Stop();
var elapsedMs = stopwatch.ElapsedMilliseconds;
// Add the total time to a response header
context.Response.Headers["X-Request-Duration-Ms"] = elapsedMs.ToString();
});
The same effect can be achieved in a more compact and readable way by encapsulating the logic into a class.
public class RequestTimingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestTimingMiddleware> _logger;
public RequestTimingMiddleware(RequestDelegate next, ILogger<RequestTimingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var stopwatch = Stopwatch.StartNew();
_logger.LogInformation("Handling request: {Method} {Path}",
context.Request.Method, context.Request.Path);
await _next(context);
stopwatch.Stop();
var elapsedMs = stopwatch.ElapsedMilliseconds;
_logger.LogInformation("Finished handling request in {ElapsedMilliseconds} ms", elapsedMs);
// Add the duration as a response header
context.Response.Headers["X-Request-Duration-Ms"] = elapsedMs.ToString();
}
}
You can add middleware classes to the pipeline in a generic way using the following:
app.UseMiddleware<RequestTimingMiddleware>();
Most often, though, an extension method is created to register the middleware in a more business-oriented or readable way.
public static class RequestTimingMiddlewareExtensions
{
public static IApplicationBuilder UseRequestTiming(this IApplicationBuilder app)
{
return app.UseMiddleware<RequestTimingMiddleware>();
}
}
As a result, the code below becomes perfectly valid:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// Use the custom middleware with the extension method
app.UseRequestTiming();
In this way, you can add the middleware to any app by simply calling one simple method UseRequestTiming without repeating code or registration steps. Finally, keep in mind that middleware components run in the order in which they are registered within the pipeline.
Collecting Endpoints
You can add endpoints to the ASP.NET Core application in many ways. You use MapGet, MapPost and the like to register Minimal API endpoints. You use MapControllerRoute or just MapControllers for MVC endpoints. Note that with MapControllerRoute you have both conventional and attribute-based routing enabled for action methods. You only have attribute-based routing if you use MapControllers.
The collection that holds all endpoints is initialized in Build but actual endpoints are collected progressively. Minimal API endpoints are added by each call executed to MapXXX methods. Controller routes, instead, are discovered when MapControllers or MapControllerRouteis invoked. ASP.NET Core uses reflection to scan all loaded assemblies for types that:
- Inherit from ControllerBase (for APIs) or Controller (for MVC with views)
- Are public and not abstract
These discovered controller classes are also registered in the DI container so they can be instantiated per request.
Resolving an Incoming Request
All endpoints are stored in a purpose-built endpoint data source, which the routing middleware uses to locate matches for incoming requests. Each endpoint carries metadata such as:
- Route pattern
- HTTP method constraints (GET, POST, etc.)
- Required services or policies (e.g., authorization, CORS)
When a request arrives, the routing middleware (UseRouting) inspects the request path, HTTP method, and other constraints, iterating through all registered endpoints to find candidates that could handle the request. The selection process involves:
- Comparing route templates against the request path
- Matching HTTP method constraints
- Checking applicable policies (e.g., authorization)
Once the best match is found, the routing middleware assigns the corresponding endpoint to the current HttpContext. The endpoint’s associated request delegate is then retrieved via HttpContext.GetEndpoint() and invoked to generate the response.
Executing the Request
It is interesting to look in more detail at how an endpoint is executed. The process depends on the endpoint type. Broadly speaking, there are two main categories of endpoints in ASP.NET Core: Minimal API handlers and MVC controller actions.
For a Minimal API endpoint, ASP.NET Core creates a request delegate that’s essentially a runtime-compiled lambda. This delegate reads data from the HTTP context, performs parameter binding to match the lambda’s arguments, invokes the lambda, and serializes the result into the HTTP response. Serialization uses the configured output formatter, typically JSON. For example, given this endpoint:
app.MapGet("/hello", (string name) => $"Hello {name}!");
the resulting delegate conceptually looks like:
RequestDelegate del = async httpContext =>
{
var name = httpContext.Request.Query["name"];
var result = $"Hello {name}!";
await httpContext.Response.WriteAsync(result);
};
In the case of MVC controllers, the endpoint still maps to a request delegate, but the invoker is now an instance of the more sophisticated Controller Action Invoker. This component:
- Resolves the controller instance from the DI container
- Uses model binding and validation to populate action parameters
- Calls the action method via reflection
- Handles filters (authorization, resource, action, exception, result)
- Executes result processing – either serializing the return value or executing an IActionResult
As you can see, this process is more flexible and feature-rich, but also heavier than the minimal API delegate path.
Dealing with More Specific Endpoints
To be precise, Minimal API and controller methods are not the only types of endpoints in an ASP.NET Core application. Other kinds of endpoints—such as SignalR hubs, gRPC services, and GraphQL APIs—can also be hosted within the same runtime. They all follow a conceptually similar request-handling model, though each requires its own specific implementation details.
SignalR hubs. Registered using methods like MapHub and stored alongside other endpoints. When a request matches a hub path, the SignalR middleware takes over and upgrades the HTTP connection to a WebSocket or Server-Sent Events (SSE) connection. The request delegate for a hub endpoint is effectively the hub pipeline itself, managing connections, groups, and message dispatching.
gRPC services. Registered with methods such as MapGrpcService and stored with the other endpoints. The gRPC middleware requires HTTP/2 and inspects incoming requests to route them to the correct service method. The request delegate for a gRPC endpoint handles deserializing the request, invoking the service method, and serializing the response.
GraphQL endpoints. They are not built into ASP.NET Core by default, but they are fully supported through specialized middleware that integrates with the same endpoint and routing infrastructure. A popular implementation is HotChocolate. By calling methods such as MapGraphQL, the GraphQL endpoint is stored alongside all the others. The middleware reads the request body, parses the query, executes the schema, and returns the result.
From ASP.NET Core’s perspective, whether it is a SignalR hub, a gRPC service, or a GraphQL API, each of these endpoints is simply a request delegate in the pipeline, executed like any other endpoint.
The Final Step
The Run method is the final step that transitions the application from setup to live execution: the pipeline is finalized, the server starts listening, and incoming requests are actively processed. More specifically, when you call Run, ASP.NET Core takes the previously defined middleware and endpoint configuration and builds the final RequestDelegate object. This delegate represents the full chain of middleware and endpoint handlers that will process all incoming HTTP requests. At this point, the pipeline is frozen: no further middleware can be added, and the dependency injection container is immutable.
Calling Run also starts the underlying web server—typically Kestrel, though other servers are possible. The server begins listening on the configured URLs and ports (from appsettings.json, environment variables, or launchSettings.json). Once running, it accepts incoming HTTP requests and passes each one through the compiled RequestDelegate pipeline. The call to Run blocks the calling thread, keeping the application alive until the host is shut down. Meanwhile, all request handling, logging, and middleware processing occurs asynchronously and non-blocking, allowing the server to efficiently handle multiple concurrent requests.
A Note on Threads
The thread blocked by Run is not used to serve web requests; it is essentially the host’s main thread, simply waiting for the application to shut down. Incoming requests are handled on thread-pool threads managed by the runtime and Kestrel.
Since thread-pool threads are a limited and valuable resource, it’s important to avoid blocking them. Using async/await ensures that threads are not held idle during potentially long-running I/O operations, such as network or disk access, allowing the server to efficiently handle many concurrent requests.
Summary
After creating the application builder, calling Build finalizes services and configuration while preparing the middleware pipeline and endpoints. You can still add middleware or map endpoints at this stage. Calling Run compiles the request-processing pipeline and starts the web server, making the application ready to handle requests. From this point, the pipeline is immutable, and all requests flow through the configured middleware and endpoints, whether Minimal APIs, controllers, or specialized endpoints like SignalR, gRPC, or GraphQL.