Request Timeouts and Limits
Bound how long and how large a request may be with request timeout policies, Kestrel limits and the RequestAborted token.
builder.Services.AddRequestTimeouts(o => o.DefaultPolicy = new RequestTimeoutPolicy { Timeout = TimeSpan.FromSeconds(10) });
app.UseRequestTimeouts();
app.MapGet("/reports", handler).WithRequestTimeout(TimeSpan.FromSeconds(60));An API without limits lets one slow dependency or one huge upload tie up threads, memory and connections for everyone. ASP.NET Core gives you two layers: Kestrel's server limits, enforced while reading the request, and the request timeouts middleware, which bounds how long your code may take.
Request timeouts middleware
Register with builder.Services.AddRequestTimeouts(...) and add app.UseRequestTimeouts() after routing so per-endpoint policies are visible. Apply a timeout with .WithRequestTimeout(TimeSpan), a named policy .WithRequestTimeout("reports"), the [RequestTimeout] attribute, or a DefaultPolicy for everything; .DisableRequestTimeout() opts out.
The mechanism matters: the middleware does not kill anything. It links a timer to HttpContext.RequestAborted and cancels that token when time is up. Code that passes the token to Task.Delay, HttpClient, EF Core or Npgsql stops; code that ignores it runs to completion and the timeout has no effect. When the token fires and an OperationCanceledException reaches the middleware before the response has started, it writes the policy's TimeoutStatusCode, 504 by default, or runs WriteTimeoutResponse. Timeouts are not applied while a debugger is attached, so test them without one.
Kestrel limits
Underneath the middleware sits a server-level size and time layer: Kestrel caps body size, header size and slow clients before your code runs; keep those caps small and raise body limits per endpoint with [RequestSizeLimit] or IHttpMaxRequestBodySizeFeature. Set them in code with builder.WebHost.ConfigureKestrel(k => k.Limits...), or from a Kestrel:Limits section once it is bound with builder.Services.Configure<KestrelServerOptions>(builder.Configuration.GetSection("Kestrel")); without that line the section does nothing. The defaults table and the binding details are in the Kestrel:Limits reference lesson (net-key-kestrel-limits).
What a team decides
- A default timeout shorter than the load balancer's idle timeout (often 60 s), so the app answers with a clean 504 instead of the proxy cutting the connection.
- Longer named policies for known slow endpoints (exports, reports) and no timeout for streaming endpoints such as Server-Sent Events.
- Every handler accepts a
CancellationTokenand passes it down; without that, neither client disconnects nor timeouts free any resources. - A small global body limit (1 to 10 MB for JSON APIs) and explicit larger limits on upload endpoints.
Production failure modes
A timeout that "does nothing" is almost always a handler that drops the token. A 504 logged while the database query keeps running means the token was not passed to the query. A TaskCanceledException logged as an error on every client disconnect means code treats cancellation as failure; catch OperationCanceledException when RequestAborted.IsCancellationRequested and log it at a lower level.
Example
When to use it
- Return 504 after 300 ms when the warehouse inventory lookup hangs, instead of holding the request open for minutes.
- Cap JSON request bodies at 64 KB globally while allowing 100 MB only on POST /imports/products.
- Give GET /reports/sales a 60-second named timeout policy while every other endpoint keeps a 10-second default.
More examples
Kestrel limits in appsettings.json
<code>WebApplication.CreateBuilder</code> does not bind <code>Kestrel:Limits</code>: the section, and environment variables such as <code>Kestrel__Limits__MaxRequestBodySize</code>, take effect only after <code>builder.Services.Configure<KestrelServerOptions>(builder.Configuration.GetSection("Kestrel"))</code>, shown in the comment above the JSON. Without it the server keeps its 30,000,000-byte and 130-second defaults. Keeping keep-alive above the proxy's idle timeout means the proxy closes idle connections first, avoiding random 502s. See net-key-kestrel-limits.
{
// Only takes effect with this line in Program.cs:
// builder.Services.Configure<KestrelServerOptions>(builder.Configuration.GetSection("Kestrel"));
"Kestrel": {
"Limits": {
// 10 MB for a JSON API; upload endpoints raise it per request.
"MaxRequestBodySize": 10485760,
// Longer than the load balancer's idle timeout (60 s), so the proxy, not the app, closes idle connections.
"KeepAliveTimeout": "00:01:15",
"RequestHeadersTimeout": "00:00:15"
}
}
}A timeout policy that returns ProblemDetails
The default policy covers every endpoint with 10 seconds and a ProblemDetails body, so clients get the same error shape as for other failures. The named <code>reports</code> policy grants 60 seconds to one known slow endpoint instead of raising the limit for the whole API.
using Microsoft.AspNetCore.Http.Timeouts;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
builder.Services.AddRequestTimeouts(options =>
{
options.DefaultPolicy = new RequestTimeoutPolicy
{
Timeout = TimeSpan.FromSeconds(10),
TimeoutStatusCode = StatusCodes.Status504GatewayTimeout,
WriteTimeoutResponse = async context =>
{
var problems = context.RequestServices.GetRequiredService<IProblemDetailsService>();
await problems.WriteAsync(new ProblemDetailsContext
{
HttpContext = context,
ProblemDetails =
{
Title = "The request took too long",
Status = StatusCodes.Status504GatewayTimeout,
Detail = "Try again, or narrow the date range of the report."
}
});
}
};
options.AddPolicy("reports", TimeSpan.FromSeconds(60));
});
var app = builder.Build();
app.UseRequestTimeouts();
app.MapGet("/reports/sales", async (DateOnly from, DateOnly to, CancellationToken ct) =>
{
await Task.Delay(TimeSpan.FromSeconds(1), ct);
return TypedResults.Ok(new { from, to, revenue = 184_220.50m });
}).WithRequestTimeout("reports");
app.Run();Opting a streaming endpoint out of the timeout
A default policy would cut a Server-Sent Events stream after 15 seconds. <code>DisableRequestTimeout()</code> removes it for that endpoint, and <code>IHttpRequestTimeoutFeature.DisableTimeout()</code> does the same from inside a request. The stream still ends when the client disconnects because <code>RequestAborted</code> flows into the iterator.
using System.Runtime.CompilerServices;
using Microsoft.AspNetCore.Http.Timeouts;
var builder = WebApplication.CreateSlimBuilder(args);
builder.Services.AddRequestTimeouts(options =>
options.DefaultPolicy = new RequestTimeoutPolicy { Timeout = TimeSpan.FromSeconds(15) });
var app = builder.Build();
app.UseRequestTimeouts();
// Long-lived stream: excluded from the default policy.
app.MapGet("/orders/{id:int}/status-stream", (int id, CancellationToken ct) =>
TypedResults.ServerSentEvents(StatusUpdates(id, ct), eventType: "status"))
.DisableRequestTimeout();
// Or decide inside the request, for example after authorising a long export.
app.MapGet("/exports/orders.csv", async (HttpContext context) =>
{
context.Features.Get<IHttpRequestTimeoutFeature>()?.DisableTimeout();
context.Response.ContentType = "text/csv";
await context.Response.WriteAsync("id,status,total\n1001,Paid,129.90\n", context.RequestAborted);
});
app.Run();
static async IAsyncEnumerable<string> StatusUpdates(int id, [EnumeratorCancellation] CancellationToken ct)
{
foreach (var status in new[] { "Paid", "Picking", "Shipped" })
{
await Task.Delay(TimeSpan.FromSeconds(5), ct);
yield return $"order {id}: {status}";
}
}
Discussion