Kestrel and Server Limits
Know Kestrel's default limits, configure HTTP/2 and HTTP/3 endpoints, align keep-alive with your proxy, and tell when tuning the server is the wrong fix.
builder.WebHost.ConfigureKestrel(k => {
k.Limits.MaxRequestBodySize = 1_048_576;
// only if the proxy's idle timeout is under 75 s
k.Limits.KeepAliveTimeout = TimeSpan.FromSeconds(75);
k.AddServerHeader = false; });Kestrel is the HTTP server inside every ASP.NET Core app. Its defaults suit an API behind a load balancer, and most teams never need to change more than one or two limits. When latency is bad, the cause is almost always the application (blocking calls, slow queries, a saturated pool) rather than Kestrel. Tune limits to protect the service and to match the infrastructure around it.
Defaults that matter
Limit (KestrelServerOptions.Limits) | Default | Why you would change it |
|---|---|---|
MaxConcurrentConnections | null (unlimited) | Rarely; cap at the load balancer or with a concurrency limiter instead |
MaxRequestBodySize | 30,000,000 bytes | Lower it for JSON APIs; raise it per upload endpoint |
KeepAliveTimeout | 130 seconds | Must be longer than the proxy's idle timeout |
RequestHeadersTimeout | 30 seconds | Lower it on internet-facing Kestrel |
MinRequestBodyDataRate | 240 bytes/s after a 5 s grace | Drops slow-drip uploads (slowloris); relax per endpoint for slow mobile uploads |
Http2.MaxStreamsPerConnection | 100 | Raise for gRPC clients that multiplex many calls |
A body over MaxRequestBodySize fails with 413 when the endpoint reads it, as the example shows. Set a small global value and raise it for the one upload endpoint through IHttpMaxRequestBodySizeFeature before the body is read.
Keep-alive and the proxy
Proxies reuse connections to the pods. If Kestrel closes an idle connection at the moment the proxy sends a request on it, the proxy returns 502 to the client. The rule: the side that sends requests (the proxy) must give up an idle connection first, so its upstream idle timeout must be shorter than Kestrel's KeepAliveTimeout. With the default 130 seconds and a load balancer idle timeout of 60 seconds this holds; it breaks when someone lowers Kestrel's timeout or raises the load balancer's for long-polling.
HTTP/2 and HTTP/3
Endpoints default to Http1AndHttp2. Browsers and most clients negotiate HTTP/2 through TLS (ALPN). Without TLS there is no negotiation, so such an endpoint serves HTTP/1.1; cleartext HTTP/2 (h2c) needs an Http2-only endpoint and a client that speaks it from the first byte, which is how gRPC is usually run inside a cluster. HTTP/2 multiplexes streams on one connection, so 100 concurrent gRPC calls to one pod use one connection; past MaxStreamsPerConnection extra calls wait unless the client opens more connections (SocketsHttpHandler.EnableMultipleHttp2Connections).
HTTP/3 runs over QUIC (UDP) and needs TLS and, on Linux, the libmsquic package in the image. Enable it with HttpProtocols.Http1AndHttp2AndHttp3; Kestrel advertises it with an alt-svc header, so the first request still uses HTTP/1.1 or HTTP/2. Behind a load balancer that terminates TLS, HTTP/3 is the balancer's concern, not Kestrel's.
When to tune
Tune after a measurement points at a limit: 413s on a legitimate upload, 502s that line up with idle timeouts, gRPC calls queueing on one connection. Do not raise limits to make a load test pass; find the bottleneck first, usually the thread pool, the database or the connection pool. Also set AddServerHeader = false so responses stop announcing Server: Kestrel.
Example
When to use it
- Stop intermittent 502s from an AWS ALB with a 60-second idle timeout after someone lowered Kestrel's KeepAliveTimeout to 30 seconds
- Limit JSON endpoints to 1 MB bodies while allowing 50 MB invoice scans on one upload route
- Serve REST on 8080 and cleartext gRPC on 8081 from one pod with separate Http1 and Http2 endpoints
More examples
Limits and endpoints in appsettings.json
The default builder reads only <code>Kestrel:Endpoints</code>, <code>EndpointDefaults</code> and <code>Certificates</code>. <code>AddServerHeader</code> and everything under <code>Limits</code> are ignored until <code>Program.cs</code> binds the section with <code>builder.Services.Configure<KestrelServerOptions>(builder.Configuration.GetSection("Kestrel"))</code>. Without that line the app still runs with the 30,000,000-byte body limit and the 130-second keep-alive (the Kestrel:Limits reference lesson shows this). With the binding, limits can differ per environment without a rebuild, and environment variables such as <code>Kestrel__Limits__MaxRequestBodySize</code> override them. Two endpoints separate REST traffic on 8080 from cleartext gRPC on 8081, because an <code>Http1AndHttp2</code> endpoint without TLS would serve only HTTP/1.1. The 75-second <code>KeepAliveTimeout</code> is only safe because this load balancer drops idle connections at 60 seconds; check your own balancer's value before lowering Kestrel's 130-second default.
// Endpoints are read automatically. AddServerHeader and Limits apply only with
// builder.Services.Configure<KestrelServerOptions>(builder.Configuration.GetSection("Kestrel"));
{
"Kestrel": {
"AddServerHeader": false,
"Limits": {
"MaxRequestBodySize": 1048576,
// Only because the load balancer's idle timeout is 60 s; it must stay below this
"KeepAliveTimeout": "00:01:15",
"RequestHeadersTimeout": "00:00:15",
"Http2": {
"MaxStreamsPerConnection": 200
}
},
"Endpoints": {
"Http": {
"Url": "http://0.0.0.0:8080",
"Protocols": "Http1"
},
"Grpc": {
"Url": "http://0.0.0.0:8081",
"Protocols": "Http2"
}
}
}
}
A larger, slower upload on one endpoint
The server-wide limit stays at 1 MB, which protects every JSON endpoint, and only the scan upload raises it to 50 MB and relaxes the minimum data rate. Both features become read-only once the body is being read, so this code has to run before model binding or any middleware touches <code>Request.Body</code>. An endpoint that accepts 50 MB needs the other controls too: authorization, a route constraint on the id, a server-generated file name, and deleting or moving the temp file so uploads cannot fill the disk.
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.AspNetCore.Server.Kestrel.Core.Features;
using Microsoft.AspNetCore.Http.Features;
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(k => k.Limits.MaxRequestBodySize = 1024 * 1024);
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorization();
var app = builder.Build();
// The route constraint rejects anything but INV-123456 before the handler runs.
app.MapPost("/invoices/{invoiceId:regex(^INV-[0-9]{{6}}$)}/scan", async (string invoiceId, HttpContext context, CancellationToken ct) =>
{
// Must run before anything reads the body; afterwards the feature is read-only.
var size = context.Features.Get<IHttpMaxRequestBodySizeFeature>();
if (size is { IsReadOnly: false }) size.MaxRequestBodySize = 50L * 1024 * 1024;
// Scanned PDFs come from branch offices on slow links.
var rate = context.Features.Get<IHttpMinRequestBodyDataRateFeature>();
if (rate is not null) rate.MinDataRate = new MinDataRate(bytesPerSecond: 100, gracePeriod: TimeSpan.FromSeconds(30));
// The file name comes only from a GUID, never from caller input.
var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.pdf");
try
{
long bytes;
await using (var file = File.Create(path))
{
await context.Request.Body.CopyToAsync(file, ct);
bytes = file.Length;
}
// Real code moves the file to durable storage (a blob container) here.
return TypedResults.Accepted($"/invoices/{invoiceId}/scan", new { invoiceId, bytes });
}
finally
{
File.Delete(path); // temp files left behind fill the container's disk
}
}).RequireAuthorization();
app.Run();
HTTP/3 alongside HTTP/1.1 and HTTP/2
HTTP/3 needs TLS and listens on UDP 8443 next to TCP 8443, so firewalls and Kubernetes Services must allow both. Clients first connect over TCP, see the <code>alt-svc</code> response header and switch on later requests. On Linux the image must contain <code>libmsquic</code>; without it Kestrel logs a warning and serves only HTTP/1.1 and HTTP/2.
using System.Net;
using Microsoft.AspNetCore.Server.Kestrel.Core;
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(kestrel =>
{
kestrel.Listen(IPAddress.Any, 8443, listen =>
{
listen.Protocols = HttpProtocols.Http1AndHttp2AndHttp3;
// Uses the certificate from Kestrel:Certificates:Default in configuration.
listen.UseHttps();
});
});
var app = builder.Build();
app.MapGet("/orders/{id:int}", (int id, HttpRequest request) =>
TypedResults.Ok(new { id, protocol = request.Protocol }));
app.Run();
Discussion