ASP.NET Core 6 and IAsyncEnumerable - Async Streamed JSON vs NDJSON
Recently I've published code and written posts about working with asynchronous streaming data sources over HTTP using NDJSON. One of the follow-up questions I've received was how does it relate to async streaming coming in ASP.NET Core 6. As the answer isn't obvious, I've decided to describe the subject in more detail.
ASP.NET Core and IAsyncEnumerable
Since ASP.NET Core 3, IAsyncEnumerable
can be returned directly from controller actions.
[Route("api/[controller]")]
[ApiController]
public class WeatherForecastsController : Controller
{
...
[HttpGet("weather-forecast")]
public IAsyncEnumerable<WeatherForecast> GetWeatherForecastStream()
{
async IAsyncEnumerable<WeatherForecast> streamWeatherForecastsAsync()
{
for (int daysFromToday = 1; daysFromToday <= 10; daysFromToday++)
{
WeatherForecast weatherForecast = await _weatherForecaster.GetWeatherForecastAsync(daysFromToday);
yield return weatherForecast;
};
};
return streamWeatherForecastsAsync();
}
}
The ASP.NET Core will iterate IAsyncEnumerable
in an asynchronous manner, buffer the result, and send it down the wire. The gain here is no blocking of calls and no risk of thread pool starvation, but there is no streaming of data to the client. If one would like to test this by making some requests, a possible first attempt could look like this.
private static async Task ConsumeJsonStreamAsync()
{
Console.WriteLine($"[{DateTime.UtcNow:hh:mm:ss.fff}] Receving weather forecasts . . .");
using HttpClient httpClient = new();
using HttpResponseMessage response = await httpClient.GetAsync(
"...",
HttpCompletionOption.ResponseHeadersRead
).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
IAsyncEnumerable<WeatherForecast> weatherForecasts = await response.Content
.ReadFromJsonAsync<IAsyncEnumerable<WeatherForecast>>().ConfigureAwait(false);
await foreach (WeatherForecast weatherForecast in weatherForecasts)
{
Console.WriteLine($"[{DateTime.UtcNow:hh:mm:ss.fff}] {weatherForecast.Summary}");
}
Console.WriteLine($"[{DateTime.UtcNow:hh:mm:ss.fff}] Weather forecasts has been received.");
}
This will fail. Until .NET 6 System.Text.Json
doesn't support IAsyncEnumerable
, the only option is to use IEnumerable
.
private static async Task ConsumeJsonStreamAsync()
{
...
IEnumerable<WeatherForecast> weatherForecasts = await response.Content
.ReadFromJsonAsync<IEnumerable<WeatherForecast>>().ConfigureAwait(false);
foreach (WeatherForecast weatherForecast in weatherForecasts)
{
...
}
...
}
The output will look like below (assuming it takes around 100ms to generate a single forecast).
[08:12:59.184] Receving weather forecasts . . .
[08:13:01.380] Cool
[08:13:01.381] Warm
[08:13:01.381] Sweltering
[08:13:01.381] Hot
[08:13:01.381] Chilly
[08:13:01.382] Scorching
[08:13:01.382] Hot
[08:13:01.382] Freezing
[08:13:01.382] Chilly
[08:13:01.382] Bracing
[08:13:01.382] Weather forecasts has been received.
Here the gain of properly using NDJSON is clear, as in such case the output would look more like this.
[08:13:01.400] Receving weather forecasts . . .
[08:13:01.538] Mild
[08:13:01.633] Freezing
[08:13:01.755] Mild
[08:13:01.862] Warm
[08:13:01.968] Warm
[08:13:02.075] Sweltering
[08:13:02.184] Freezing
[08:13:02.294] Chilly
[08:13:02.401] Freezing
[08:13:02.506] Hot
[08:13:02.513] Weather forecasts has been received.
Async Streaming in ASP.NET Core 6
One of ASP.NET Core improvements in .NET 6 is support for async streaming of IAsyncEnumerable
. In .NET 6, System.Text.Json
can serialize incoming IAsyncEnumerable
in asynchronous manner. Thanks to that, the ASP.NET Core no longer buffers IAsyncEnumerable
at ObjectResult
level, the decision is made at output formatter level and the buffering occurs only in the case of Newtonsoft.Json
based one.
Also, deserialization to IAsyncEnumerable
is now supported by System.Text.Json
, so the client code which was failing now works.
private static async Task ConsumeJsonStreamAsync()
{
...
IAsyncEnumerable<WeatherForecast> weatherForecasts = await response.Content
.ReadFromJsonAsync<IAsyncEnumerable<WeatherForecast>>().ConfigureAwait(false);
await foreach (WeatherForecast weatherForecast in weatherForecasts)
{
...
}
...
}
Unfortunately, the result of running that code is disappointing. There is no difference from deserialization to IEnumerable
. That shouldn't be a surprise as DeserializeAsync
method (which is being used under the hood) signature doesn't allow streaming. This is why a new API, DeserializeAsyncEnumerable
method, has been introduced to handle streaming deserialization.
private static async Task ConsumeJsonStreamAsync()
{
...
Stream responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
IAsyncEnumerable<WeatherForecast> weatherForecasts = JsonSerializer.DeserializeAsyncEnumerable<WeatherForecast>(
responseStream,
new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
DefaultBufferSize = 128
});
await foreach (WeatherForecast weatherForecast in weatherForecasts)
{
...
}
...
}
This time I was surprised because the result also didn't change. As I had no idea if my expectations about the behavior were correct, I've decided to ask. Turns out I've hit a bug. I've quickly downgraded from Preview 6 to Preview 4 and finally achieved the result I was fishing for.
[08:28:51.662] Receving weather forecasts . . .
[08:28:51.967] Cool
[08:28:52.068] Sweltering
[08:28:52.288] Cool
[08:28:52.289] Freezing
[08:28:52.396] Freezing
[08:28:52.614] Cool
[08:28:52.614] Cool
[08:28:52.723] Cool
[08:28:52.851] Cool
[08:28:52.851] Chilly
[08:28:52.854] Weather forecasts has been received.
You may have noticed that I've set DefaultBufferSize
while passing JsonSerializerOptions
to the DeserializeAsyncEnumerable
method. This is very important if one wants to achieve streaming behavior. Internally, DeserializeAsyncEnumerable
will read from the stream until the buffer is full or the stream has ended. If the buffer size is large (and the default is 16KB) there will be a significant delay in asynchronous iteration (in fact you can see irregularity in the above output resulting from exactly that).
The Conclusion
Async Streaming in ASP.NET Core 6 will allow achieving similar effects to NDJSON, if you understand your data very well and know how to configure the deserialization. That's the main difference from NDJSON. In the case of NDJSON streaming capability is a consequence of the format. It is possible to implement it on top of JSON serializers/deserializers available in different platforms and languages. In the case of async streaming, it's a consequence of serializer/deserializer internal nature. That internal nature will be different on different platforms. The first example coming to mind are browsers.
The good thing is that in ASP.NET Core 6 one doesn't have to choose between async streaming and NDJSON. Because ObjectResult
is no longer buffering IAsyncEnumerable
, content negotiation becomes possible. I'm showing exactly that capability in ASP.NET Core 6 branch of my demo project (which will become main after GA).