Book a call

Field notes ยท lab reproduction

Six Seconds of Silence

If Kestrel serves your ASP.NET Core app, every upload it reads runs against a clock you never set. The clock is strict about the first few seconds, lenient after that, and it stops whenever a debugger is attached.

In our .NET 10 lab, a client started an upload, then sent nothing for eight seconds while it waited for its own data. That happens when the request body comes from a query that hasn't returned yet. Once the data was ready, the client resumed writing.

By then, the server had already closed the connection, at 6.1 seconds. The client didn't find out until 8.0 seconds, when its next write failed with "An established connection was aborted by the software in your host machine."

Nobody had configured a limit. Kestrel's default minimum request body data rate is 240 bytes per second, and according to the Kestrel documentation it is not enforced while a debugger is attached. By the documentation and the Kestrel source, the same upload goes through when you step through it on your own machine.

We ran 32 scenarios, each twice, to see what the limit measures, which uploads it cuts off, and what the client and server report when it does. Here is what we found and how to fix it. One fix you will find online makes the problem worse.

The rule

Kestrel's defaults:

SettingDefault
MinRequestBodyDataRate240 bytes per second, 5 s grace period
MinResponseDataRate240 bytes per second, 5 s grace period
How often Kestrel checksevery second

The documentation says the grace period allows for TCP slow start. The Kestrel source shows how the rate is calculated: only the time your code spends waiting for body bytes that haven't arrived counts. Once that waiting time exceeds the grace period, Kestrel divides all the bytes received so far by the seconds spent waiting. If the result is below 240, it ends the request.

How long a client can go quiet therefore depends on how much it has already sent. For a body that arrives in one quick burst and then stops, the allowance is roughly the bytes sent divided by 240, in seconds, and never less than the five-second grace period. Any earlier waiting uses up part of it. We tested this with a client that sent a burst and then stopped:

Sent before the pausePredicted limitKestrel ended the request at
nothing5 s6.0 s
1 KB5 s6.0 s
4 KB17.1 s18.1 s
16 KB68.3 s69.5 s

Kestrel ended each request about a second after the predicted limit, which fits a check that runs once per second. By the same arithmetic, a client that sent 1 MB in a quick burst could then pause for about 73 minutes.

That is the surprise. 240 bytes per second is under 2 kilobits per second, so the limit catches bodies that take too long to start or keep arriving in small amounts:

  • A client streams a body as it produces it, but has to wait for the first piece: an export waiting on a slow query, a file being compressed on the fly. In our lab, an 8-second pause before the first byte was cut at 6.1 seconds. A 4-second pause passed, and so did the same 8-second pause after 64 KB.
  • A request stays open and sends a small message now and then. Our client sent 100 bytes every second and was cut at 6.0 seconds, after the server had read 600 bytes.

gRPC's ASP.NET Core server turns this limit off for client-streaming and duplex calls. A streaming endpoint you write yourself keeps the default.

What each side sees

The HTTP/1.1 client never saw the 408 that Kestrel recorded. It discovered the broken connection only when a later write failed, as an HttpRequestException wrapping "Unable to write data to the transport connection". A client that paused for 20 seconds found out at 20, although the server had given up at 6.0. The client sending 100 bytes a second even wrote once more, at 7.0 seconds, before a write failed at 8.0. In the client's logs, this looks like a network fault.

The server's logs depend on how the endpoint reads the body. With the logging levels from the ASP.NET Core template:

EndpointResponse statusServer log at template levels
Reads Request.Body itself, no try/catch408one fail entry with the exception
Minimal API with a JSON-bound parameter408nothing
The same plain endpoint, over HTTP/2none, the connection is abortednothing

Only the first row tells you what went wrong:

fail: Microsoft.AspNetCore.Server.Kestrel[13] Connection id "...", Request id "...": An unhandled exception was thrown by the application.
Microsoft.AspNetCore.Server.Kestrel.Core.BadHttpRequestException: Reading the request body timed out due to data arriving too slowly. See MinRequestBodyDataRate.

Kestrel logs its own message for this event at Debug level, and the template sets Microsoft.AspNetCore to Warning. Minimal API's JSON binding catches the exception and sets 408. Its log message is also at Debug level.

Kestrel's kestrel.connection.duration metric caught it every time: every connection the limit ended carried error.type = min_request_body_data_rate.

On HTTP/2, a quiet upload can take down the whole connection

Kestrel measures the HTTP/2 data rate across the whole connection. If the rate falls too low, it aborts the connection, including every other request sharing it. We repeated the 8-second pause over HTTP/2:

  • With the upload alone on its connection, Kestrel cut it off at 6.0 seconds. This time, the client noticed immediately.
  • When a streaming download shared the connection, it died along with the upload, after 12 KB.
  • When a busy upload shared the connection, the paused upload survived. The busy neighbor kept the connection's average above 240.

So on HTTP/2, the same pause can end a connection or pass without trouble, depending on what else is using it.

HTTP/1.1 / client pauses 8 s after sending 64 KBHTTP 200 at 8.0 sclientserverHTTP/1.1 / client pauses 8 s before the first byteclient error at 8.0 sclientserverKestrel ends it 6.1 sclient unawareHTTP/2 / same pause, a download on the same connectionboth fail at 6.1 suploaddownloadcut after 12 KBclient sends nothingrequest open on the serversuccessfailure0 s12345678910 s
Three uploads with an 8 s pause, measured in our lab. Only the first one had sent data before it went quiet.

The fix, and the change that makes it worse

The only answer on a Microsoft Q&A thread about this error suggests raising the rate to 1,024 bytes per second with a 10-second grace period. Three later comments point out that this goes the wrong way. They're right: the setting is a minimum, so raising it makes Kestrel stricter. An upload that sent 4 KB and then paused for 15 seconds passed with the defaults. With the higher rate, Kestrel cut it off at 11.1 seconds.

The Kestrel options page shows a lower rate: 100 bytes per second with a 10-second grace period. That gives clients more time while keeping the limit in place. An 8-second pause before the first byte passed. A 15-second pause was cut off at 11.1 seconds, as was a client sending 100 bytes every two seconds. This setting also loosens the limit for every endpoint on the server.

What worked for us was turning the limit off on the one endpoint whose clients pause, before anything reads the body, and adding a timeout that suits those clients. In Program.cs, with the using at the top of the file and the endpoint after var app = builder.Build();:

using Microsoft.AspNetCore.Server.Kestrel.Core.Features;

app.MapPost("/import", async (HttpContext ctx) =>
{
    // Before the first read. Kestrel picks up the rate when the body read starts.
    ctx.Features.Get<IHttpMinRequestBodyDataRateFeature>()!.MinDataRate = null;

    var buffer = new byte[16 * 1024];
    while (true)
    {
        int n;
        using (var idle = CancellationTokenSource.CreateLinkedTokenSource(ctx.RequestAborted))
        {
            idle.CancelAfter(TimeSpan.FromSeconds(30)); // 30 s without a byte is too long
            try
            {
                n = await ctx.Request.Body.ReadAsync(buffer, idle.Token);
            }
            catch (OperationCanceledException)
                when (idle.IsCancellationRequested && !ctx.RequestAborted.IsCancellationRequested)
            {
                return Results.StatusCode(StatusCodes.Status408RequestTimeout);
            }
        }
        if (n == 0) break;
        // process buffer[..n]; the idle clock is not running here
    }
    return Results.Ok();
});

The idle timer covers one read at a time, so time spent processing a chunk doesn't count against the client. With this endpoint, the 15-second pause and the client sending 100 bytes every two seconds both completed. When a client went silent for 45 seconds, our endpoint returned 408 at 30 seconds. The client was still paused and only found out when it next wrote, at 45. The rest of the server kept the default limit.

That replacement timeout matters. Kestrel's security considerations page lists the minimum rate as the protection against slow POST attacks, and of the timeouts on that page it is the only one that watches the request body. Set it to null and that endpoint has no such protection until you add your own limit. An idle timeout only limits silence: a client sending one byte every 29 seconds can keep the request open for a very long time without triggering it. For a finite import, cap the total time as well and keep a limit on the body size.

On HTTP/2, the per-request switch can only turn the check off. Setting the rate to null works; setting a lower rate throws NotSupportedException. In our lab, that exception turned the upload into an HTTP 500 before the client had sent anything.

Where the clock runs, and where it doesn't

Before ending a request for being too slow, Kestrel checks Debugger.IsAttached, which reports a managed debugger. Starting the app with F5 in Visual Studio attaches one. None of our lab runs had a debugger attached, and each run file records this. We did not run the scenarios under a debugger, so that behavior comes from the documentation and the Kestrel source.

These limits belong to Kestrel. With IIS in-process hosting, IIS HTTP Server handles requests, so the settings do not apply. With out-of-process hosting, IIS forwards requests to Kestrel, so they do. We did not test IIS.

The response side has the same default of 240 bytes per second, and we could not get it to fire. Kestrel enforces it only while a write is waiting on the client, and every write adds its size divided by 240 to the deadline, which gives a large download plenty of time. In our test, a client read nothing from a 64 MB download for 60 seconds and still received all of it afterwards.

Check your own services

  1. Search your code and configuration for MinRequestBodyDataRate. A raised value makes this failure more likely.
  2. Check the kestrel.connection.duration metric for error.type = min_request_body_data_rate, and search your logs for MinRequestBodyDataRate. The metric catches the cases the logs miss.
  3. List the endpoints whose clients stream a body as they produce it or keep a request open. For each one, ask whether the first bytes could take more than five seconds to arrive.
  4. Test those uploads without a debugger (Ctrl+F5 in Visual Studio, or the published build), and have the test client pause before its first byte.
  5. For endpoints that must tolerate pauses, set MinDataRate to null for that request before the body is read, and add your own idle timeout. Keep the server-wide default in place.

What we measured

  • A pause of more than about five seconds before the first byte ended the upload. The same pause after 64 KB did not.
  • The HTTP/1.1 client discovered the broken connection when a later write failed. It never saw the 408.
  • At the template's logging levels, the minimal API JSON endpoint and the uploads cut off over HTTP/2 left nothing in the log. The Kestrel metric recorded every one.
  • On HTTP/2, a paused upload also took down a download sharing its connection.
  • Raising the rate made the limit stricter, and lowering it moved the cutoff. Turning it off for one endpoint and adding an idle timeout fixed the problem.

How we ran this

We used .NET 10.0.14 (SDK 10.0.403) on Windows 11. Kestrel and HttpClient ran in the same process over 127.0.0.1, with no NuGet packages. HTTP/2 used cleartext with prior knowledge. The client streamed request bodies from a custom HttpContent, with chunked transfer encoding over HTTP/1.1 and DATA frames over HTTP/2. Logging used the levels from the ASP.NET Core template.

We ran all 32 scenarios twice. Every scenario ended the same way both times; only the exception type the HTTP/2 client reported changed between runs. The numbers in this article come from the first run of each set. Between runs, the times at which Kestrel ended a request moved by at most 0.12 seconds.