ASP.NET Core Disable Response Buffering

Viewed 8073

I'm attempting to stream a large JSON file built on the fly to a client (could be 500 MB+). I'm trying to disable response buffering for a variety of reasons, though mostly for memory efficiency.

I've tried writing directly to the HttpContext.Response.BodyWriter but the response seems to be buffered in memory before writing to the output. The return type of this method is Task.

HttpContext.Response.ContentType = "application/json";
HttpContext.Response.ContentLength = null;
await HttpContext.Response.StartAsync(cancellationToken);
var bodyStream = HttpContext.Response.BodyWriter.AsStream(true);
await bodyStream.WriteAsync(Encoding.UTF8.GetBytes("["), cancellationToken);
await foreach (var item in cursor.WithCancellation(cancellationToken)
    .ConfigureAwait(false))
{
    await bodyStream.WriteAsync(JsonSerializer.SerializeToUtf8Bytes(item, DefaultSettings.JsonSerializerOptions), cancellationToken);
    await bodyStream.WriteAsync(Encoding.UTF8.GetBytes(","), cancellationToken);
    
    await bodyStream.FlushAsync(cancellationToken);
    await Task.Delay(100,cancellationToken);
}
await bodyStream.WriteAsync(Encoding.UTF8.GetBytes("]"), cancellationToken);
bodyStream.Close();
await HttpContext.Response.CompleteAsync().ConfigureAwait(false);

Note: I realize this code is very hacky, trying to make it work, then clean it up

I'm using the Task.Delay to verify the response is not being buffered when testing locally as I do not have full production data. I have also tried IAsyncEnumerable and yield return, but that fails because the response is so large that Kestrel thinks the enumerable is infinite.

I've tried

  1. Setting KestrelServerLimits.MaxResponseBufferSize to a small number, even 0;
  2. Writing with HttpContext.Response.WriteAsync
  3. Writing with HttpContext.Response.BodyWriter.AsStream()
  4. Writing with a pipe writer patter and HttpContext.Response.BodyWriter
  5. Removing all middleware
  6. Removing calls to IApplicationBuilder.UseResponseCompression

Update

  1. Tried disabling response buffering before setting the ContentType (so before any writes to the response) with no effect
var responseBufferingFeature = context.Features.Get<IHttpResponseBodyFeature>();
responseBufferingFeature?.DisableBuffering();

Updated Sample Code

This reproduces the issue quite simply. The client doesn't receive any data until response.CompleteAsync() is called.

[HttpGet]
[Route("stream")]
public async Task<EmptyResult> FileStream(CancellationToken cancellationToken)
{
    var response = DisableResponseBuffering(HttpContext);
    HttpContext.Response.Headers.Add("Content-Type", "application/gzip");
    HttpContext.Response.Headers.Add("Content-Disposition", $"attachment; filename=\"player-data.csv.gz\"");
    await response.StartAsync().ConfigureAwait(false);
    var memory = response.Writer.GetMemory(1024*1024*10);
    response.Writer.Advance(1024*1024*10);
    await response.Writer.FlushAsync(cancellationToken).ConfigureAwait(false);
    await Task.Delay(5000).ConfigureAwait(false);
    var str2 = Encoding.UTF8.GetBytes("Bar!\r\n");
    memory = response.Writer.GetMemory(str2.Length);
    str2.CopyTo(memory);
    response.Writer.Advance(str2.Length);
    await response.CompleteAsync().ConfigureAwait(false);
    return new EmptyResult();
}

private IHttpResponseBodyFeature DisableResponseBuffering(HttpContext context)
{
    var responseBufferingFeature = context.Features.Get<IHttpResponseBodyFeature>();
    responseBufferingFeature?.DisableBuffering();
    return responseBufferingFeature;
}
3 Answers

Try to disable buffering on response futures:

HttpContext.Features.Get<IHttpResponseBodyFeature>().DisableBuffering()
//As mentioned in documentation, to take effect, call it before any writes

And use BodyWriter in Utf8JsonWriter for more efficiency:

 var pipe = context.HttpContext.Response.BodyWriter;
 await pipe.WriteAsync(startArray);
 using (var writer = new Utf8JsonWriter(pipe,
            new JsonWriterOptions
            {
                Indented = option.WriteIndented,
                Encoder = option.Encoder,
                SkipValidation = true
            }))
 {
      var dotSet = false;
      foreach (var item in enumerable)
      {
           if (dotSet)
               await pipe.WriteAsync(dot);
           JsonSerializer.Serialize(writer, item, itemType, option);
           await pipe.FlushAsync();
           writer.Reset();
           dotSet = true;
      }
 }
 await pipe.WriteAsync(endArray);

In my case it give results: total memory allocation become greater over 80% compared to newcoreapp2.2 after first requests, but no more memory leaks.

For those who is still interested this code sends data right away when using curl:

public async Task Invoke(HttpContext context)
{
    var g = context.Features.Get<IHttpResponseBodyFeature>();
    g.DisableBuffering(); // doesn't seem to make a difference

    context.Response.StatusCode = 200;
    context.Response.ContentType = "text/plain; charset=utf-8";
    //context.Response.ContentLength = null;

    await g.StartAsync();

    for (int i = 0; i < 10; ++i)
    {
        var line = $"this is line {i}\r\n";
        var bytes = utf8.GetBytes(line);
        // it seems context.Response.Body.WriteAsync() and
        // context.Response.BodyWriter.WriteAsync() work exactly the same
        await g.Writer.WriteAsync(new ReadOnlyMemory<byte>(bytes));
        await g.Writer.FlushAsync();
        await Task.Delay(1000);
    }

    await g.CompleteAsync();
}

Variations I tried with and without DisableBufering() as well as writing to a pipe (IHttpResponseBodyFeature.Writer vs HttpContext.Response.Body) didn't seem to make a difference.

In curl it shows messages right away, however in Chrome and some rest clients it waits for the whole stream to show up.

So I would recommend testing your code behavior with a client that doesn't wait for the whole stream to present it. Another option I am still checking if aspnet core automatically picks up compression possibility if client asks for it even though compression is not configured in the pipeline. So I would recomm

I was able to get this working when using http.sys (with ASP.NET Core 6):

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;

public class Program
{
    public static void Main(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);
        builder.WebHost.UseHttpSys();
        var app = builder.Build();
        app.MapGet("/", async (context) =>
        {
            context.Response.StatusCode = 201;
            await context.Response.StartAsync();
            await context.Response.WriteAsync("x"); // client gets status code after this line
            await context.Response.WriteAsync("Hello World!");
        });
        app.Run();
    }
}
Related