Hosting and the pipeline
The path of a request in ASP.NET Core: which server accepts it, how it flows through the middleware pipeline, how routing picks a handler, and how the host starts and stops the app. These questions show whether a candidate understands what happens between the network and their own code.
Before this topic: Async.
What is Kestrel, and why put IIS or a reverse proxy in front of it?
Section titled “What is Kestrel, and why put IIS or a reverse proxy in front of it?”What it is
- Kestrel — the web server built into ASP.NET Core. It listens on a port and turns an HTTP request into an
HttpContextfor the middleware pipeline. - Reverse proxy — a server in front of the app that forwards requests from the network to Kestrel.
Why a proxy
- One port 443 for several sites: Kestrel can’t share a port with other processes.
- Certificates and HTTPS are configured in one place.
- Load balancing across app instances.
- An extra layer of defense.
How to use
WebApplication.CreateBuilder enables Kestrel for you. With no configuration it listens on http://localhost:5000. Behind a proxy, call app.UseForwardedHeaders() first in the pipeline.
Common mistake
Forgetting forwarded headers. The app sees the proxy’s IP instead of the client’s and http instead of https, and UseHttpsRedirection ends up in a redirect loop.
Takeaway: Kestrel handles HTTP inside the app; the proxy guards the entry point from the network and shares it between sites.
How it works
- A transport accepts the connection: a TCP socket, a Unix socket behind Nginx or, since .NET 8, a Windows named pipe.
- If HTTPS is enabled, the connection goes through TLS via
SslStream. - An HTTP/1.1, HTTP/2 or HTTP/3 parser reads bytes from
System.IO.Pipelinesand creates anHttpContext. - The request enters the middleware pipeline on a thread pool thread.
When to choose what
| Environment | Option |
|---|---|
| Linux, VM | Kestrel behind Nginx, YARP or Apache |
| Kubernetes | Kestrel behind an ingress or load balancer |
| Windows Server with IIS | IIS and the ASP.NET Core Module |
| Windows without IIS, shared port needed | HTTP.sys |
Pitfalls
- By default, forwarded headers are accepted only from loopback addresses. Add your proxy’s address to
KnownProxies, or the headers are silently ignored. ForwardLimitdefaults to 1: only the last value ofX-Forwarded-Forin a proxy chain is read.- Kestrel’s limits (
MaxRequestBodySizeis 30,000,000 bytes, header timeouts) and the proxy’s limits apply at the same time. The smaller one wins.
Rule of thumb
One app and nowhere else to terminate TLS — Kestrel can face the network itself. Several sites on one IP or centralized TLS — put a proxy in front and configure forwarded headers.
Takeaway: a proxy is an infrastructure decision, and configuring forwarded headers is a mandatory part of it.
Under the hood
- Kestrel reads and writes through
System.IO.Pipelineswith buffers from its own memory pool, so headers are parsed with almost no allocations. Since .NET 10 the Kestrel, IIS and HTTP.sys pools release memory when idle. - Sockets are serviced by IOCP or epoll, while app code runs on the thread pool. A blocking call in a handler takes a thread away from every connection.
- With IIS in-process there is no Kestrel: a request goes from HTTP.sys through IIS straight into
IISHttpServerinsidew3wp.exe.
Cost
- A proxy means an extra hop and a second HTTP parse. That’s usually a fraction of a millisecond, but under load it’s also connections, buffers and CPU on a second server.
- Response buffering in Nginx breaks streaming and Server-Sent Events, and proxy timeouts kill long-lived WebSockets unless you tune them.
- TLS on Kestrel spends the app’s CPU. TLS on the proxy offloads the app, but traffic inside the network travels unencrypted.
Trade-offs
- Trusting
X-Forwarded-Foris a security boundary. A wideKnownIPNetworks(KnownNetworksbefore .NET 10) orForwardLimit = nulllets a client spoof its IP and bypass per-IP limits. ASPNETCORE_FORWARDEDHEADERS_ENABLED=trueturns on forwarded headers without restricting proxy addresses. That’s convenient behind a cloud load balancer and dangerous if the app is reachable directly.
How to check in production
dotnet-counters monitorwith the .NET 8+ metrics fromMicrosoft.AspNetCore.Server.Kestrel:kestrel.active_connections,kestrel.queued_connections,kestrel.rejected_connections,kestrel.tls_handshake.duration.- Logs show the proxy’s IP instead of client IPs — turn on Debug for forwarded headers: the middleware logs “Unknown proxy” with the address missing from the trusted list.
Takeaway: a proxy moves TLS, limits and trust to the edge, and the price is latency, buffering and careful forwarded-headers configuration.
using System.Net;using Microsoft.AspNetCore.HttpOverrides;
var builder = WebApplication.CreateBuilder(args);
// Kestrel's own limitsbuilder.WebHost.ConfigureKestrel(options => options.Limits.MaxRequestBodySize = 10 * 1024 * 1024); // 10 MB
// The proxy (Nginx, YARP) passes the client IP and scheme in X-Forwarded-*builder.Services.Configure<ForwardedHeadersOptions>(options =>{ options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; options.KnownProxies.Add(IPAddress.Parse("10.0.0.10")); // trust only our own proxy});
var app = builder.Build();
app.UseForwardedHeaders(); // first, so the rest see the real scheme and IPapp.UseHttpsRedirection();
app.MapGet("/", (HttpContext ctx) => $"{ctx.Request.Scheme} {ctx.Connection.RemoteIpAddress}"); // https 203.0.113.5
app.Run();- Is there Kestrel when hosting in IIS? — Out-of-process, yes; in-process, no:
IISHttpServerruns insidew3wp.exeinstead. - Why does
UseForwardedHeadersgo first? — The rest of the middleware must see the real scheme and client IP. - How is HTTP.sys different from Kestrel? — Windows-only, built on a kernel driver, supports port sharing and Windows authentication, and can’t be combined with IIS.
- Can Kestrel face the internet directly? — Yes, it’s a supported configuration, but the port can’t be shared with other processes.
- Why does the HTTPS redirect loop behind Nginx? — The proxy terminated TLS, and without forwarded headers the app treats every request as HTTP.
What is middleware, and why does its order matter?
Section titled “What is middleware, and why does its order matter?”What it is
Middleware is a step in the pipeline. Each step receives an HttpContext and decides to either:
- handle the request and pass it on via
next; - respond itself and not call
next.
Why
Cross-cutting concerns — errors, HTTPS, static files, authentication, compression — live in separate steps and don’t mix with business code.
How to use
| Method | What it does |
|---|---|
app.Use(...) |
a step that calls next |
app.Run(...) |
a final step, no next |
app.Map("/path", ...) |
a pipeline branch by path prefix |
app.UseXxx() |
built-in middleware: UseExceptionHandler, UseStaticFiles |
Common mistake
Placing UseExceptionHandler after other middleware. It won’t catch exceptions from the steps above it.
Takeaway: the request passes through middleware in registration order, the response in reverse.
How it works
- A step’s code before
await next(context)runs on the way “in”. - Code after
nextruns on the way back, once the steps below have produced the response. - A step that doesn’t call
nextshort-circuits the pipeline: the steps below don’t run. Mapmoves the matched prefix fromPathtoPathBase.MapWhenbranches on a condition, andUseWhenrejoins the main pipeline after its branch.
Recommended order
UseForwardedHeaders, if the app is behind a proxy.UseExceptionHandlerandUseHsts; in Development, the developer exception page.UseHttpsRedirection.UseStaticFiles— serves the file and short-circuits.UseRouting.UseRateLimiter,UseCors.UseAuthentication,UseAuthorization.- Your own middleware, then the endpoints:
MapControllers,MapGet.
Pitfalls
- Once the response has started, headers and status can’t be changed — you get an exception. Writing to the body after
nextcan breakContent-Length. UseRateLimiterwith endpoint policies ([EnableRateLimiting]) only works afterUseRouting.- Authentication doesn’t reject anonymous requests; it only identifies the user. Authorization is what says no.
Rule of thumb
First whatever must see every request and catch every error, then whatever cheaply cuts off unneeded work, then security, then business logic.
Takeaway: middleware order is processing order, so it determines both security and performance.
Under the hood
Useadds aFunc<RequestDelegate, RequestDelegate>factory toApplicationBuilder. When the host starts,Build()walks the list from the end and wraps each step around the next one.- The result is a single
RequestDelegatethat the server invokes for every request. The order is “frozen” until restart. MapandMapWhenalso build their nested pipeline at startup, not per request.
Cost
- A step is a delegate call, and an async step also has its own state machine. A dozen lightweight middleware are nearly free.
- A slow step at the start of the pipeline slows every request, including static files and health checks. Expensive logging before
UseStaticFilesis paid for on every image.
Trade-offs
- Middleware versus MVC filters: middleware knows nothing about the model or action but works for any endpoint and for static files. Logic tied to controllers is easier to keep in filters.
- A step that needs to know the selected endpoint goes after
UseRouting. Then the decision is made from endpoint metadata rather than from a path string.
How to check in production
- Find a slow step by inserting
Stopwatchtiming between middleware — the routing docs suggest exactly this. - The
http.server.request.durationmetric (.NET 8+) compared with action time in the logs shows how much the pipeline around the endpoint costs. - Lock the order in with integration tests on
WebApplicationFactory: a request without a token gets 401, static files are served without authentication.
Takeaway: the pipeline is a chain of delegates built once at startup, and the order of its steps is part of the architecture, not a matter of style.
var builder = WebApplication.CreateBuilder(args);var app = builder.Build();
app.Use(async (context, next) =>{ Console.WriteLine("A: before"); await next(context); // on down the pipeline Console.WriteLine("A: after"); // on the way back});
app.Map("/health", health => // branch: /health is answered here health.Run(ctx => ctx.Response.WriteAsync("OK")));
app.Use(async (context, next) =>{ if (!context.Request.Headers.ContainsKey("X-Api-Key")) { context.Response.StatusCode = StatusCodes.Status401Unauthorized; return; // short-circuit: don't call next } await next(context);});
app.Run(ctx => ctx.Response.WriteAsync("Hello")); // terminal middleware
app.Run();// GET /health -> 200 OK// GET / -> 401// GET / + X-Api-Key -> 200 Hello; the console always shows A: before, A: after- How is
Usedifferent fromRun? —Usegetsnextand can pass the request on;Runis always final. - How is
Mapdifferent fromUseWhen? —Mapsends the request into a separate branch;UseWhenrejoins the main pipeline after its branch. - Where does
UseExceptionHandlergo? — Near the start: it only catches exceptions from steps registered after it. - Can you change a header after
await next(context)? — Only if the response hasn’t started (Response.HasStarted); it’s safer to subscribe in advance viaResponse.OnStarting. - Why does
UseCorscome beforeUseAuthentication? — The docs require it: CORS must run before access checks, otherwise a rejection can go out without CORS headers and the browser shows a CORS error instead of a 401.
How does an app start and stop: Generic Host and WebApplicationBuilder?
Section titled “How does an app start and stop: Generic Host and WebApplicationBuilder?”What it is
The host (Generic Host) owns everything the app needs:
- configuration and logging;
- the dependency injection container;
- background services (
IHostedService) and the web server.
How to use
WebApplication.CreateBuilder(args)— a builder with default settings.builder.Services.Add...— register services.builder.Build()— build the app.app.Use...andapp.Map...— describe the pipeline.app.Run()— start and wait for shutdown.
Lifecycle
IHostApplicationLifetime reports three events: ApplicationStarted, ApplicationStopping, ApplicationStopped. Its StopApplication() method stops the app from code.
Common mistake
Registering services after builder.Build(). The container is already built, and the call throws InvalidOperationException: the service collection is now read-only.
Takeaway: CreateBuilder configures, Build assembles, Run starts and waits for shutdown.
How it works
CreateBuildersets the content root and readsappsettings.json,appsettings.{Environment}.json, user secrets (in Development), environment variables and command-line arguments. It also adds logging and the server: Kestrel plus IIS integration.Build()creates the container. In Development, scope and dependency validation are on.Run()callsStartAsync: hosted services start one by one in registration order, then the pipeline is built, the server starts andApplicationStartedfires.- Shutdown:
ApplicationStopping→ the server stops accepting connections and waits for in-flight requests →StopAsyncon the remaining hosted services in reverse order →ApplicationStopped.
When to choose what
| Approach | When |
|---|---|
WebApplication.CreateBuilder |
a regular web app, .NET 6+ |
WebApplication.CreateSlimBuilder |
Native AOT and a minimal feature set, .NET 8+ |
Host.CreateApplicationBuilder |
a worker service without HTTP |
Host.CreateDefaultBuilder and Startup |
legacy .NET Core 3.x–5 code |
Pitfalls
- Requests that don’t finish within
ShutdownTimeout(30 seconds by default) are cut off. ApplicationStoppingcallbacks run synchronously: a slow callback delays the whole shutdown.- A slow
StartAsyncin a hosted service delays the server start — the port isn’t listening yet.
Rule of thumb
Do long initialization with await before app.Run() or in a BackgroundService, not in StartAsync. If requests can take longer than 30 seconds, set ShutdownTimeout explicitly.
Takeaway: the host starts services in order, stops them in reverse, and gives shutdown a limited time.
Under the hood
- The web server is a hosted service too (
GenericWebHostService). On start it builds the pipeline and starts theIServer: Kestrel,IISHttpServeror HTTP.sys. IHostLifetimedecides when to start and when to finish:ConsoleLifetime(Ctrl+C, SIGINT, SIGTERM, SIGQUIT),WindowsServiceLifetime,SystemdLifetime. Since .NET 6, SIGTERM is handled via POSIX signals rather thanAppDomain.ProcessExit.- .NET 8’s
IHostedLifecycleServiceaddsStartingAsync,StartedAsync,StoppingAsyncandStoppedAsync.HostOptions.ServicesStartConcurrentlyandServicesStopConcurrentlyenable concurrent start and stop.
Cost
The shutdown budget is set by the environment, and the app has to fit into it:
| Environment | Waits before a forced kill |
|---|---|
docker stop |
10 seconds |
| Kubernetes | terminationGracePeriodSeconds, 30 by default |
| IIS app pool | Shutdown Time Limit, 90 seconds by default |
If ShutdownTimeout exceeds the environment’s budget, the process is killed mid-shutdown and ApplicationStopped never runs.
Trade-offs
- A long
ShutdownTimeoutprotects requests but slows down deployments and scaling. - In Kubernetes a pod is removed from load balancing in parallel with SIGTERM. That’s why people add a short pause before shutdown (
preStop) until new requests stop arriving. WebHostBuilderandWebHostare obsolete in .NET 10 (warnings ASPDEPR004 and ASPDEPR008): new code uses the Generic Host only.
How to check in production
- The
Microsoft.Hosting.Lifetimelog category writes “Application started” and “Application is shutting down…”. They show how long shutdown took. - 502 and 503 errors on clients during deployment and
OperationCanceledExceptionin the logs are a sign that the environment’s budget is too small.
Takeaway: graceful shutdown is a set of aligned timeouts across host, server and environment, not a single ShutdownTimeout setting.
var builder = WebApplication.CreateBuilder(args);
// Give in-flight requests up to 20 seconds on shutdownbuilder.Services.Configure<HostOptions>(o => o.ShutdownTimeout = TimeSpan.FromSeconds(20));
var app = builder.Build();
IHostApplicationLifetime lifetime = app.Lifetime;lifetime.ApplicationStarted.Register(() => app.Logger.LogInformation("Started"));lifetime.ApplicationStopping.Register(() => app.Logger.LogInformation("Stopping: draining requests"));lifetime.ApplicationStopped.Register(() => app.Logger.LogInformation("Stopped"));
app.MapGet("/slow", async (CancellationToken ct) =>{ await Task.Delay(TimeSpan.FromSeconds(10), ct); // finishes in time on Ctrl+C return "done";});
app.Run(); // blocks the thread until the host stops// Ctrl+C during /slow: Stopping → "done" response → Stopped- Which comes first: a hosted service’s
StartAsyncor Kestrel starting? —StartAsyncof every hosted service first, then the server andApplicationStarted. - What happens to a request that runs longer than
ShutdownTimeout? — It gets cut off, and the client sees the connection drop. - How is
WebApplicationBuilderdifferent fromHost.CreateDefaultBuilderwithStartup? — It’s the same Generic Host, but configured linearly, without callbacks or aStartupclass. - Why doesn’t the app shut down cleanly in Docker? —
docker stopwaits 10 seconds and then sends SIGKILL;ShutdownTimeoutmust be shorter. - How do you stop the app from code? —
IHostApplicationLifetime.StopApplication().
How do you write custom middleware, and how do the options differ in lifetime?
Section titled “How do you write custom middleware, and how do the options differ in lifetime?”What it is
Custom middleware is a pipeline step with your own logic: timing, a correlation ID, a header check.
How to use
- Lambda:
app.Use(async (context, next) => { ...; await next(context); }). Good for a couple of lines. - Convention-based class:
- the constructor takes
RequestDelegate next; - a public
InvokeAsync(HttpContext context)method returnsTask; - plug it in with
app.UseMiddleware<MyMiddleware>()or your own extension methodapp.UseMy().
- the constructor takes
IMiddleware: the class implementsInvokeAsync(HttpContext, RequestDelegate)and is registered in DI.
Common mistake
Forgetting await next(context). The request goes no further, and the client gets an empty 200 response.
Takeaway: a lambda for a couple of lines, a class with a UseXxx extension method for reusable logic.
How it works
| Option | When it’s created | Scoped dependencies |
|---|---|---|
Lambda in app.Use |
once, when the pipeline is built | via context.RequestServices |
| Convention-based class | once, when the pipeline is built | as InvokeAsync parameters |
IMiddleware |
per request, from DI | in the constructor |
When to choose what
- Convention-based class — the default. No extra per-request work; settings come through
UseMiddleware<T>(args)orIOptions<T>. IMiddleware— when you need a constructor with scoped dependencies and a signature the compiler checks.
Pitfalls
- Fields of a convention-based class are shared by all requests. Per-request state in a field is a race between threads.
- A scoped service in a convention-based constructor: in Development, the exception “Cannot resolve scoped service … from root provider” at startup; in production, one instance for the whole app.
- An unregistered
IMiddlewaredoesn’t prevent startup, but the very first request gets a 500. - You can’t pass arguments to
UseMiddleware<T>(...)forIMiddleware— it throwsNotSupportedException.
Rule of thumb
The constructor is for singletons and settings; InvokeAsync is for everything scoped to a request.
Takeaway: a convention-based class lives as long as the app; IMiddleware lives for one request.
Under the hood
UseMiddleware<T>runs when the pipeline is built. For a convention-based class it findsInvokeorInvokeAsyncand creates the instance viaActivatorUtilitiesfrom the root container.- If
InvokeAsynctakes onlyHttpContext, the call is a direct delegate. Extra parameters are resolved per request fromcontext.RequestServices. - For
IMiddleware, a wrapper goes into the pipeline. On every request it getsIMiddlewareFactoryfrom the request scope, obtains an instance, invokes it and hands it back to the factory viaRelease. The container disposes the instance along with the scope.
Cost
IMiddlewareregistered as Transient means an allocation and dependency resolution on every request.- The
app.Useoverload withFunc<Task>instead ofRequestDelegateallocates two extra objects per request. Since .NET 6 there’s anapp.Use((context, next) => next(context))overload wherenextis aRequestDelegate, without those allocations.
Trade-offs
- A convention-based class is faster, but the compiler doesn’t check its contract: a typo in
InvokeAsyncsurfaces not at build time but as an exception at startup. IMiddlewareis easier to test and decorate. A customIMiddlewareFactorylets you pull middleware from a third-party container.
How to check in production
- A captured scoped service is caught by scope validation: keep it on in Development and in integration tests.
- Per-request allocations show up in
dotnet-counters(GC allocation rate) and in a load test before and after the change. - An unregistered
IMiddlewareis caught by a smoke test onWebApplicationFactorythat makes at least one request.
Takeaway: choosing an option is choosing a lifetime: a singleton with request dependencies as parameters, or an object per request.
var builder = WebApplication.CreateBuilder(args);builder.Services.AddScoped<RequestInfo>();builder.Services.AddScoped<TimingMiddleware>(); // IMiddleware must be registered in DI
var app = builder.Build();app.UseMiddleware<CorrelationMiddleware>("X-Correlation-Id"); // constructor argumentapp.UseMiddleware<TimingMiddleware>();app.MapGet("/", (RequestInfo info) => info.Id); // the same scoped object the middleware sawapp.Run();
// Convention-based: one instance for the whole apppublic class CorrelationMiddleware(RequestDelegate next, string header){ public Task InvokeAsync(HttpContext context, RequestInfo info) // scoped goes in as a parameter { info.Id = context.Request.Headers[header].FirstOrDefault() ?? Guid.NewGuid().ToString(); return next(context); }}
// IMiddleware: a new instance per request, scoped in the constructorpublic class TimingMiddleware(RequestInfo info, ILogger<TimingMiddleware> logger) : IMiddleware{ public async Task InvokeAsync(HttpContext context, RequestDelegate next) { long start = Environment.TickCount64; await next(context); logger.LogInformation("{Id}: {Ms} ms", info.Id, Environment.TickCount64 - start); }}
public class RequestInfo { public string Id { get; set; } = ""; }- Why can’t a regular middleware take
DbContextin its constructor? — The constructor runs once, so the context would be shared by all requests and threads. - How do you pass settings to middleware? — As
UseMiddleware<T>(...)arguments or viaIOptions<T>in the constructor; forIMiddleware, only through DI. - What happens if
IMiddlewareisn’t registered in DI? — The app starts, but every request through that step gets a 500. - Why the
app.Useoverload withRequestDelegate? — Since .NET 6 it saves two allocations per request compared withFunc<Task>. - How do you test middleware? — With
WebApplicationFactoryorTestServer, or by callingInvokeAsyncwith aDefaultHttpContext.
How does endpoint routing work, and how is a route chosen?
Section titled “How does endpoint routing work, and how is a route chosen?”What it is
- Endpoint — a request handler: a
MapGetdelegate, a controller action, a Razor Page, a SignalR hub. - Route — a URL template used to select an endpoint.
How to use
| Template | Meaning |
|---|---|
/orders/{id} |
parameter id |
/orders/{id:int} |
integers only |
/orders/{id?} |
optional parameter |
/{page=Home} |
default value |
/files/{**path} |
catch-all: the rest of the path |
Parameter values arrive as handler arguments: (int id) => ....
Common mistake
Validating input with route constraints. A bad value gives a 404, when the client needs a 400 with a clear message.
Takeaway: a route picks a handler by URL and method; constraints tell routes apart but don’t validate input.
How it works
UseRoutingmatches the request against all endpoints and stores the winner:context.GetEndpoint(), with parameter values inRequest.RouteValues.- Middleware between
UseRoutingandUseEndpointsreads endpoint metadata:[Authorize], the CORS policy, rate limits. UseEndpointsinvokes the endpoint’s delegate.
WebApplication (.NET 6+) adds UseRouting at the start if you didn’t call it yourself, and UseEndpoints at the end. Wherever app.MapGet appears in the code, the endpoint runs at the end of the pipeline.
How a route is chosen
Registration order doesn’t matter; the more specific template wins:
- a literal beats a parameter:
/orders/newbeats/orders/{id}; - a constrained parameter beats an unconstrained one;
- catch-all is the weakest.
If two endpoints tie on precedence, the request fails with AmbiguousMatchException. An explicit Order is a last resort.
Pitfalls
- Middleware placed before an explicit
UseRoutingdoesn’t see the endpoint:GetEndpoint()returnsnull. UseCorsandUseRateLimiterwith endpoint policies go afterUseRouting.- Ambiguity isn’t caught at startup:
/{x:alpha}and/{x:int}tie on precedence but never overlap, so routing can’t check this up front.
Rule of thumb
Move a shared prefix and shared metadata into MapGroup (.NET 7+). Make “allow or not” decisions from endpoint metadata, not from the path string.
Takeaway: UseRouting selects the endpoint, middleware after it works with its metadata, UseEndpoints executes it.
Under the hood
- On the first request, routing builds an automaton (DFA) from all templates and rebuilds it when endpoint data sources change. Matching time depends on path length, not route count.
- Candidates from the automaton are filtered by constraints and
MatcherPolicy: HTTP method, host, content type.EndpointSelectorpicks the best one byOrderand template precedence. - An endpoint is a
RequestDelegateplus anEndpointMetadataCollection. Metadata can be any object: attributes,RequireAuthorization(),WithMetadata(...).
Cost
- Regex constraints and complex segments like
{x}-{y}are noticeably more expensive than plain ones and allocate more strings. - Thousands of routes with a parameter in the leading segments (
{tenant}/...) bloat the automaton: a slow first request and lots ofDfaNodeobjects in a dump. Constraints like{tenant:int}and moving parameters toward the end of the template help.
Trade-offs
ShortCircuit()andMapShortCircuit(.NET 8+) run the endpoint right after routing and skip the rest of the middleware. That saves work for health checks androbots.txt, but it’s incompatible with[Authorize]and CORS.- Your own terminal middleware instead of an endpoint deprives authorization and CORS of metadata: you’d have to wire up that integration by hand.
How to check in production
- The
Microsoft.AspNetCore.Routinglog category at Debug level shows the candidates and the selected endpoint. - .NET 8+ metrics:
aspnetcore.routing.match_attemptswith a success flag, and thehttp.routetag onhttp.server.request.duration— latency per route template rather than per raw URL. EndpointDataSourcefrom DI gives the full list of routes.
Takeaway: routing is the single decision point: a fast automaton, predictable precedence and metadata that policies rely on.
var builder = WebApplication.CreateBuilder(args);var app = builder.Build();
app.Use(async (context, next) =>{ // The implicit UseRouting runs first: the endpoint is already selected var endpoint = context.GetEndpoint(); if (endpoint?.Metadata.GetMetadata<AuditAttribute>() is not null) app.Logger.LogInformation("Audit: {Endpoint}", endpoint.DisplayName); await next(context);});
var orders = app.MapGroup("/orders");orders.MapGet("/{id:int}", (int id) => $"by id {id}"); // /orders/5orders.MapGet("/{name}", (string name) => $"by name {name}"); // /orders/abcorders.MapGet("/new", () => "new order form") // a literal beats parameters .WithMetadata(new AuditAttribute());
app.MapGet("/robots.txt", () => "User-agent: *").ShortCircuit(); // the middleware above won't run
app.Run();
sealed class AuditAttribute : Attribute { }- What does
GetEndpoint()return in middleware beforeUseRouting? —null: no endpoint has been selected yet. - Why doesn’t
/orders/newhit/orders/{id}? — A literal segment has higher precedence than a parameter. - What happens if two routes tie on precedence? —
AmbiguousMatchExceptionat request time, not at startup. - Why
MapGroup? — A shared prefix and shared metadata (authorization, filters) for a group of endpoints, .NET 7+. - How is
ShortCircuit()different from terminal middleware? — It’s an endpoint with metadata that runs right after routing and skips the rest of the pipeline.
In-process vs out-of-process in IIS: how does the ASP.NET Core Module work, and which should you pick?
Section titled “In-process vs out-of-process in IIS: how does the ASP.NET Core Module work, and which should you pick?”What it is
The ASP.NET Core Module is an IIS module from the .NET Hosting Bundle. It connects IIS and the app in one of two ways:
- in-process — the app is loaded straight into
w3wp.exe, andIISHttpServerreplaces Kestrel; - out-of-process — ANCM starts
dotnet.exeorMyApp.exewith Kestrel and forwards requests to it.
How to use
- Install the .NET Hosting Bundle on the server: the runtime plus ANCM.
- Create a pool with .NET CLR version = No Managed Code.
- Set the model in
.csproj:<AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>(the default) orOutOfProcess. Publishing carries it intoweb.config.
Common mistake
Putting two in-process apps into one pool. The second fails to start with error 500.35: each app needs its own pool.
Takeaway: in-process means the app lives inside IIS; out-of-process means IIS acts as a proxy in front of Kestrel.
How it works
| In-process | Out-of-process | |
|---|---|---|
| Process | w3wp.exe |
dotnet.exe or MyApp.exe |
| Server | IISHttpServer |
Kestrel on a random port |
| Request path | HTTP.sys → IIS → app | HTTP.sys → IIS → loopback → Kestrel |
| App crash | w3wp dies, IIS starts a new one |
ANCM restarts the process |
| Startup error | 500.30 | 502.5 |
When to choose what
- In-process — the default: faster, no extra process or port.
- Out-of-process — single-file publishing (in-process doesn’t support it, error 500.38) or identical Kestrel behavior in IIS and elsewhere.
Pitfalls
- Pool bitness (Enable 32-Bit Applications) must match the bitness of the app and the runtime.
- The IIS
maxAllowedContentLengthlimit (30,000,000 bytes by default) is checked before the app’sMaxRequestBodySize. requestTimeout(2 minutes by default) applies only out-of-process: ANCM won’t wait for a response any longer.- A running app’s DLLs are locked. Deployments drop
app_offline.htmor enable shadow copy (.NET 7+).
Rule of thumb
In-process by default, one pool per app. Out-of-process only for a concrete reason.
Takeaway: in-process is faster and simpler; out-of-process gives you a separate process with Kestrel at the cost of an extra network hop.
Under the hood
- ANCM is the
aspnetcorev2.dllshim in the IIS pipeline plus a handler for the chosen model. - In-process: the handler loads CoreCLR into
w3wp.exeand calls the app’sMain.UseIISregistersIISHttpServer, which receives requests from the native IIS pipeline without sockets. - Out-of-process:
aspnetcorev2_outofprocess.dllstarts the process withASPNETCORE_PORTandASPNETCORE_TOKEN. Every request carries theMS-ASPNETCORE-TOKENheader; the IIS integration middleware rejects requests without it and enables forwarded headers.
Cost
- Out-of-process: a second HTTP parse, loopback connections and a separate process with its own memory.
- In-process: the app shares a process with IIS, and an app crash is a
w3wpcrash. - Pool recycling (every 1,740 minutes by default) and shutdown after 20 idle minutes mean cold starts: JIT, cache warm-up, new connections.
Trade-offs
- Overlapped recycle brings up a new
w3wpbefore stopping the old one. Two copies of the app run at once, and background jobs, file locks and in-memory caches must cope with that. - On recycle, ANCM waits
shutdownDelay(1 second by default), then the host spends up toShutdownTimeouton in-flight requests. Neither overlapped recycle nor shadow copy guarantees zero downtime — that takes blue-green. - Cold starts are removed with Application Initialization with
preloadEnabled,startMode="AlwaysRunning"and an idle timeout of 0.
How to check in production
- The Windows Event Log, source “IIS AspNetCore Module V2”.
- Temporarily
stdoutLogEnabled="true"or the ANCM debug log:debugLevelanddebugFileinhandlerSettings. - Take dumps of
w3wp.exefor in-process and ofdotnet.exefor out-of-process; attachdotnet-countersto the same process.
Takeaway: in-process gives maximum performance at the cost of sharing a process’s fate with IIS, so plan recycling and deployments around that.
var builder = WebApplication.CreateBuilder(args);
// In-process: IISHttpServer limits (IIS maxAllowedContentLength is checked first)builder.Services.Configure<IISServerOptions>(o => o.MaxRequestBodySize = 50_000_000);
var app = builder.Build();
// w3wp means in-process; dotnet or the app name means out-of-processapp.MapGet("/host", () => System.Diagnostics.Process.GetCurrentProcess().ProcessName);
app.Run();<!-- .csproj: hosting model (InProcess is the default) --><PropertyGroup> <AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel></PropertyGroup>
<!-- web.config generated on publish --><aspNetCore processPath="dotnet" arguments=".\MyApp.dll" hostingModel="inprocess" stdoutLogEnabled="false" stdoutLogFile=".\logs\stdout"> <handlerSettings> <!-- delay before the old instance shuts down on recycle, ms --> <handlerSetting name="shutdownDelay" value="5000" /> <!-- deploy without locked DLLs, .NET 7+ --> <handlerSetting name="enableShadowCopy" value="true" /> <handlerSetting name="shadowCopyDirectory" value="../ShadowCopy/" /> </handlerSettings></aspNetCore>- Why is in-process faster? — No forwarding over loopback and no second HTTP server: the request goes from IIS straight into
IISHttpServer. - What do 500.30 and 502.5 mean? — An app startup failure in-process and out-of-process respectively; look for the cause in the Event Log and the stdout log.
- Can two apps share one pool? — Two in-process apps can’t (500.35), and mixing models in one pool isn’t allowed either (500.34).
- How do you update the app without locked DLLs? —
app_offline.htmor shadow copy (.NET 7+); for zero downtime, blue-green. - Why is the first request in the morning slow? — The pool stopped on idle timeout or after a recycle; Application Initialization and
AlwaysRunninghelp.