How to Fix “Cannot Read Request Body” Errors in .NET 8

When modernizing legacy applications, one of the subtle pain points developers often face is the difference in request handling between the .NET Framework and .NET Core.

Recently, while migrating our legacy API from .NET Framework 4.5 to .NET 8, we ran into an interesting issue: our global exception logger suddenly started throwing its own exceptions!

1

The Background

In our older .NET Framework implementation, we had a global exception logger that captured additional request details whenever an unhandled exception occurred.

For example, the logger would read data like:

  • Client code or tenant ID from the HTTP request body

  • Custom headers for traceability

  • User context from the current HTTP request

This was easy to do in the classic ASP.NET pipeline — you could read the request body multiple times without much trouble.

The Problem in .NET 8

After migration, we noticed the logger started failing whenever it tried to read the HTTP request body again.

Here’s the simplified code pattern that was working earlier:

using (var reader = new StreamReader(HttpContext.Current.Request.InputStream))
{
    var body = reader.ReadToEnd();
    // Log additional info based on body content
}

But in .NET 8 (ASP.NET Core), the same approach throws errors like:

System.InvalidOperationException: The request body can only be read once.

Because in ASP.NET Core, the request body stream is not rewindable by default — once you’ve read it, it’s gone.

This design improves performance, but it also means you need to explicitly enable buffering if you plan to read it again (for example, in middleware or global exception handlers).

The Root Cause

In .NET Framework, the underlying request stream was seekable and could be re-read freely.

In .NET Core (including .NET 8), the request stream is forward-only and non-seekable by default — this prevents redundant buffering and helps with performance for large payloads.

So when your middleware or exception handler tries to read the body after it’s already been consumed (e.g., by the MVC pipeline), .NET throws an exception.

The Fix: Enable Request Buffering

The solution is simple — enable request buffering before reading the body.

Here’s how you can do it safely in your middleware or global exception handler:

public async Task InvokeAsync(HttpContext context)
{
    try
    {
        await _next(context);
    }
    catch (Exception ex)
    {
        // Enable buffering so we can re-read the request
        context.Request.EnableBuffering();
        // Read the body again
        context.Request.Body.Position = 0;
        using var reader = new StreamReader(context.Request.Body);
        var body = await reader.ReadToEndAsync();
        // Log useful context info
        _logger.LogError(ex, "Error occurred. Request Body: {Body}", body);
        // Reset position for downstream middleware (optional)
        context.Request.Body.Position = 0;
        throw;
    }
}

What’s Happening Here

  • EnableBuffering() → Tells ASP.NET Core to buffer the request stream.

  • Position = 0 → Rewinds the stream so it can be read again.

  • The buffered stream is stored temporarily (in memory or on disk for large payloads).

A Word of Caution

Alright, great — we’ve got buffering enabled, and everything’s working again.
Feels good, right? 😎

Before you celebrate too early, there’s something important to remember.

While buffering solves the problem, it comes with a memory tradeoff:

  • Large request bodies mean more RAM usage.

  • If you only need specific metadata (e.g., headers or route params), prefer logging those instead of the entire body.

Always enable buffering conditionally, and avoid reading large payloads unnecessarily.

Key Takeaways

  1. In .NET 8, request streams are one-time read by default.

  2. Use HttpRequest.EnableBuffering() before re-reading the request.

  3. Reset the stream position to 0 after reading.

  4. Avoid reading large request bodies unless necessary.

Final Thoughts

Migration isn’t just about changing syntax or upgrading frameworks — it’s about understanding new runtime behaviors and design philosophies.

In this case, ASP.NET Core’s design around non-seekable request streams is a performance optimization, not a bug.

Once you know how to enable buffering, you can safely log contextual data just like before — now with better control and efficiency.

If you’ve recently migrated a legacy API and hit a few surprises like this one, share your experience below — someone else in the community is probably facing the same thing.