I added request-body logging to an API one afternoon, the kind where you dump the raw payload so you can see exactly what a client sent. Harmless, right? The next morning a teammate pinged me because one of the POST endpoints had started rejecting perfectly good orders with validation errors. The payload looked fine in the logs. The handler swore the body was empty. Both were telling the truth.
The request body is a forward-only stream. Once something reads it to the end, the next reader gets nothing. My logging middleware read it to log it, and by the time model binding looked, there was nothing left to bind.
I wanted to see the byte counts instead of just nodding along to that explanation, so I built the smallest thing that reproduces it. One middleware that reads the body, one handler that reads it again, and a client that POSTs a real JSON payload over loopback. This is .NET 10, Release build, in a small Linux container. Nothing fancy.
Here's the shape of it. A middleware stands in for the "log every request body" code, and the handler does what your real endpoint would do when it reads the body itself:
app.Use(async (context, next) =>
{
var read = await ReadBodyBytes(context.Request.Body);
Console.WriteLine($" [middleware] read {read} bytes from the body");
await next(context);
});
app.MapPost("/orders", async (HttpContext ctx) =>
{
var read = await ReadBodyBytes(ctx.Request.Body);
Console.WriteLine($" [handler] read {read} bytes from the body");
return Results.Ok(new { bytesHandlerSaw = read });
});
ReadBodyBytes just copies the stream into a MemoryStream and returns the length. The client POSTs a 61-byte JSON body. Here's what came back:
Client is POSTing a 61-byte JSON body.
== WITHOUT EnableBuffering ==
[middleware] read 61 bytes from the body
[handler] read 0 bytes from the body
[client] server answered 200: {"bytesHandlerSaw":0}
There it is. The middleware got all 61 bytes. The handler got zero, and never knew anything was wrong. If that handler had been doing model binding instead, you'd get a null model or a "required field missing" error, and you'd spend a while staring at a request that looks completely valid in your logs.
The one line that fixes it
The fix is HttpRequest.EnableBuffering(). It swaps the raw request stream for one you can seek, so after you read to the end you can set Position = 0 and read it again. Call it before the first read, and rewind after:
app.Use(async (context, next) =>
{
context.Request.EnableBuffering();
var read = await ReadBodyBytes(context.Request.Body);
Console.WriteLine($" [middleware] read {read} bytes from the body");
context.Request.Body.Position = 0; // rewind for whoever reads next
await next(context);
});
Same run, buffering on:
== WITH EnableBuffering ==
[middleware] read 61 bytes from the body
[handler] read 61 bytes from the body
[client] server answered 200: {"bytesHandlerSaw":61}
Both readers see 61 bytes now. The rewind is the part people forget, by the way. EnableBuffering alone makes the stream seekable, but it doesn't move the position back for you. If you read to the end and don't reset, the next reader still starts at the end and still gets zero. The seekable stream just means the zero is now your fault instead of the framework's.
What EnableBuffering actually costs
This isn't free, and the name undersells what it does. Behind the scenes it wraps the body in a FileBufferingReadStream. Small bodies stay in memory, but once a request crosses a threshold (around 30 KB by default) the buffer spills to a temporary file on disk. So if you turn this on globally and someone uploads a 200 MB file, you've just written 200 MB to disk that you may not have needed to.
My opinion, for what it's worth: reading the request body in middleware at all is the thing to be suspicious of, not just the buffering. If you can get what you need from the already-parsed model in the handler, or log a hash or a size instead of the full payload, do that and never touch the raw stream. Reach for EnableBuffering when you genuinely need two readers of the same bytes, cap it to the routes that need it, and set a reasonable buffer limit rather than flipping it on for the whole app.
When you do need it, here's the whole runnable sample. It builds the same tiny app twice, once without buffering and once with, and POSTs to each one so you can watch the counts yourself:
using System.Text;
var payload = """{"orderId":4821,"sku":"KB-87","qty":2,"note":"leave at door"}""";
Console.WriteLine($"Client is POSTing a {Encoding.UTF8.GetByteCount(payload)}-byte JSON body.\n");
await RunScenario("WITHOUT EnableBuffering", enableBuffering: false, 5080, payload);
Console.WriteLine();
await RunScenario("WITH EnableBuffering", enableBuffering: true, 5081, payload);
static async Task RunScenario(string label, bool enableBuffering, int port, string payload)
{
Console.WriteLine($"== {label} ==");
var builder = WebApplication.CreateBuilder();
builder.Logging.ClearProviders();
builder.WebHost.UseUrls($"http://127.0.0.1:{port}");
var app = builder.Build();
app.Use(async (context, next) =>
{
if (enableBuffering)
context.Request.EnableBuffering();
var read = await ReadBodyBytes(context.Request.Body);
Console.WriteLine($" [middleware] read {read} bytes from the body");
if (enableBuffering)
context.Request.Body.Position = 0;
await next(context);
});
app.MapPost("/orders", async (HttpContext ctx) =>
{
var read = await ReadBodyBytes(ctx.Request.Body);
Console.WriteLine($" [handler] read {read} bytes from the body");
return Results.Ok(new { bytesHandlerSaw = read });
});
await app.StartAsync();
using var http = new HttpClient(new SocketsHttpHandler { UseProxy = false });
var content = new StringContent(payload, Encoding.UTF8, "application/json");
var resp = await http.PostAsync($"http://127.0.0.1:{port}/orders", content);
Console.WriteLine($" [client] server answered {(int)resp.StatusCode}: {await resp.Content.ReadAsStringAsync()}");
await app.StopAsync();
}
static async Task<int> ReadBodyBytes(Stream body)
{
using var ms = new MemoryStream();
await body.CopyToAsync(ms);
return (int)ms.Length;
}
Drop that into a Microsoft.NET.Sdk.Web project and dotnet run -c Release. The byte counts are the whole point: 61, then 0, then 61 again.
Have you been bitten by the empty-body-on-second-read thing? I'm curious whether it got you in middleware like me or somewhere sneakier, like an exception filter. Tell me where it bit you.
— still measuring bytes nobody asked me to count
Top comments (0)