ASP.NET Core 9 and IAsyncEnumerable - Async Streaming JSON and NDJSON From Blazor WebAssembly
I've been exploring working with asynchronous streaming data sources over HTTP for quite some time on this blog. I've been playing with async streamed JSON and NDJSON. I've been streaming and receiving streams in ASP.NET Core and console applications. But there is one scenario I haven't touched yet - streaming from Blazor WebAssembly. Why? Simply it wasn't possible.
Streaming objects from Blazor WebAssembly requires two things. First is the support for streaming upload in browsers Fetch API. The Fetch API did support streams for response bodies for quite some time (my first post using it is from 2019) but non-experimental support for streams as request bodies come to Chrome, Edge, and Safari in 2022 (and it is yet to come to Firefox as far as I can tell). Still, it's been available for two years and I haven't explored it yet? Well, I did, more than a year ago, with NDJSON and pure Javascript, where I used the ASP.NET Core endpoint I created long ago. But here we are talking about Blazor WebAssembly, which brings us to the second requirement - the browser API needs to be surfaced in Blazor. This is finally happening with .NET 9 and now I can fully explore it.
Async Streaming NDJSON From Blazor WebAssembly
I've decided to start with NDJSON because I already have my own building blocks for it (that mentioned ASP.NET Core endpoint, and NdjsonAsyncEnumerableContent
coming from one of my packages). If things wouldn't work I would be able to easily debug them.
I've quickly put together a typical code using HttpClient
. I just had to make sure I've enabled streaming upload by calling SetBrowserRequestStreamingEnabled
on the request instance.
private async Task PostWeatherForecastsNdjsonStream()
{
...
try
{
....
HttpRequestMessage request = new HttpRequestMessage(
HttpMethod.Post, "api/WeatherForecasts/stream");
request.Content = new NdjsonAsyncEnumerableContent<WeatherForecast>(
StreamWeatherForecastsAsync());
request.SetBrowserRequestStreamingEnabled(true);
using HttpResponseMessage response = await Http.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
}
finally
{
...
}
}
It worked! No additional changes and no issues to resolve. I could see the items being logged by my ASP.NET Core service as they were streamed.
After this initial success, I could move to a potentially more challenging scenario - async streaming JSON.
Async Streaming JSON From Blazor WebAssembly
In theory, async streaming JSON should also just work. .NET has the support for IAsyncEnumerable
built into JsonSerializer.SerializeAsync
so JsonContent
should just use it. Well, there is no better way than to try, so I've quickly changed the code.
private async Task PostWeatherForecastsJsonStream()
{
...
try
{
...
HttpRequestMessage request = new HttpRequestMessage(
HttpMethod.Post, "api/WeatherForecasts/stream");
request.Content = JsonContent.Create(StreamWeatherForecastsAsync());
request.SetBrowserRequestStreamingEnabled(true);
using HttpResponseMessage response = await Http.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
}
finally
{
...
}
}
To my surprise, it also worked! I couldn't test it against my ASP.NET Core service because it didn't support it, but I quickly created a dumb endpoint that would simply dump whatever was incoming.
Now, why was I surprised? Because this is in fact a platform difference - this behavior seems to be unique for the "browser" platform. I did attempt the same in a desktop application earlier and HttpClient
would buffer the request - I had to create a simple HttpContent
that would wrap the underlying stream and flush on write. I think I like the Blazor WebAssembly behavior better and I'm happy that I didn't have to do the same here.
Ok, but if I can't handle the incoming stream nicely in ASP.NET Core, it's not really that useful. So I've tried to achieve that as well.
Receiving Async Streamed JSON in ASP.NET Core
Receiving async streamed JSON is a little bit more tricky. JsonSerializer
has a dedicated method (DeserializeAsyncEnumerable
) to deserialize an incoming JSON stream into an IAsyncEnumerable
instance. The built-in SystemTextJsonInputFormatter
doesn't use that method, it simply uses JsonSerializer.DeserializeAsync
with request body as an input. That shouldn't be a surprise, it is the standard way to deserialize incoming JSON. To support async streamed JSON, a custom formatter is required. What is more, to be sure that this custom formatter will be used, it can't be just added - it has to replace the built-in one. But that would mean that the custom formatter has to have all the functionality of the built-in one (if we want to support not only async streamed JSON). Certainly, not something I wanted to reimplement, so I've decided to simply inherit from SystemTextJsonInputFormatter
.
internal class SystemTextStreamedJsonInputFormatter : SystemTextJsonInputFormatter
{
...
public override Task<InputFormatterResult> ReadRequestBodyAsync(InputFormatterContext context)
{
return base.ReadRequestBodyAsync(context);
}
}
Now, for the actual functionality, the logic has to branch based on the type of the model as we want to use DeserializeAsyncEnumerable
only if it's IAsyncEnumerable
.
internal class SystemTextStreamedJsonInputFormatter : SystemTextJsonInputFormatter
{
...
public override Task<InputFormatterResult> ReadRequestBodyAsync(InputFormatterContext context)
{
if (context.ModelType.GetGenericTypeDefinition() == typeof(IAsyncEnumerable<>))
{
...
}
return base.ReadRequestBodyAsync(context);
}
}
That's not the end of challenges (and reflection). JsonSerializer.DeserializeAsyncEnumerable
is a generic method. That means we have to get the actual type of values, based on it construct the right method, and call it.
internal class SystemTextStreamedJsonInputFormatter : SystemTextJsonInputFormatter
{
...
public override Task<InputFormatterResult> ReadRequestBodyAsync(InputFormatterContext context)
{
if (context.ModelType.GetGenericTypeDefinition() == typeof(IAsyncEnumerable<>))
{
MethodInfo deserializeAsyncEnumerableMethod = typeof(JsonSerializer)
.GetMethod(
nameof(JsonSerializer.DeserializeAsyncEnumerable),
[typeof(Stream), typeof(JsonSerializerOptions), typeof(CancellationToken)])
.MakeGenericMethod(context.ModelType.GetGenericArguments()[0]);
return Task.FromResult(InputFormatterResult.Success(
deserializeAsyncEnumerableMethod.Invoke(null, [
context.HttpContext.Request.Body,
SerializerOptions,
context.HttpContext.RequestAborted
])
));
}
return base.ReadRequestBodyAsync(context);
}
}
The implementation above is intentionally ugly, so it doesn't hide anything and can be understood more easily. You can find a cleaner one, which also performs some constructed methods caching, in the demo repository.
Now that we have the formatter, we can create a setup for MvcOptions
that will replace the SystemTextJsonInputFormatter
with a custom implementation.
internal class SystemTextStreamedJsonMvcOptionsSetup : IConfigureOptions<MvcOptions>
{
private readonly IOptions<JsonOptions>? _jsonOptions;
private readonly ILogger<SystemTextJsonInputFormatter> _inputFormatterLogger;
public SystemTextStreamedJsonMvcOptionsSetup(
IOptions<JsonOptions>? jsonOptions,
ILogger<SystemTextJsonInputFormatter> inputFormatterLogger)
{
_jsonOptions = jsonOptions;
_inputFormatterLogger = inputFormatterLogger;
}
public void Configure(MvcOptions options)
{
options.InputFormatters.RemoveType<SystemTextJsonInputFormatter>();
options.InputFormatters.Add(
new SystemTextStreamedJsonInputFormatter(_jsonOptions?.Value, _inputFormatterLogger)
);
}
}
A small convenience method to register that setup.
internal static class SystemTextStreamedJsonMvcBuilderExtensions
{
public static IMvcBuilder AddStreamedJson(this IMvcBuilder builder)
{
...
builder.Services.AddSingleton
<IConfigureOptions<MvcOptions>, SystemTextStreamedJsonMvcOptionsSetup>();
return builder;
}
}
And we can configure the application to use it.
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers()
...
.AddStreamedJson();
...
}
...
}
It gets the job done. As the formatter for NDJSON expects a different content type (application/x-ndjson
) the content negotiation also works and I could asynchronously stream NDJSON and JSON to the same endpoint. Honestly, I think it's cool.
Afterthoughts
One question that arises at this point is "Which one to use?". I prefer NDJSON here. Why? Both require custom formatters and both formatters must use reflection. But in the case of NDJSON that is isolated only to the requests coming with a dedicated content type. To support async streamed JSON, the initial model type check has to happen on every request with JSON-related content type. Also I don't feel great with replacing the built-in formatter like that. On top of that, NDJSON enables clean cancellation as the endpoint is receiving separate JSON objects. In the case of JSON that is a single JSON collection of objects that will not be terminated properly and it will trip the deserialization.
But this is just my opinion. You can grab the demo and play with it, or use one of my NDJON packages and experiment with it yourself. That's the best way to choose the right approach for your scenario.