ASP.NET Core 6 and IAsyncEnumerable - Receiving Async Streamed JSON in Blazor WebAssembly
Back in July, I've shared my experiments around new JSON async streaming capabilities in ASP.NET Core 6. Last week I've received a question about utilizing these capabilities in the Blazor WebAssembly application. The person asking the question has adopted the DeserializeAsyncEnumerable
based client code, but it didn't seem to work properly. All the results were always displayed at once. As I didn't have a Blazor WebAssembly sample as part of my streaming JSON objects demo, I've decided I'll add one and figure out the answer to the question along the way.
Blazor WebAssembly and IAsyncEnumerable
Before I focus on the problem of results not being received in an async stream manner, I think it is worth discussing the way of working with IAsyncEnumerable
in Blazor WebAssembly. What's the challenge here? The first one is that await foreach
can't be used in the page markup, only in the code block. So the markup must use a synchronous loop.
<table>
<thead>
<tr>
<th>Date</th>
<th>Temp. (C)</th>
<th>Temp. (F)</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
@foreach (WeatherForecast weatherForecast in weatherForecasts)
{
<tr>
<td>@weatherForecast.DateFormatted</td>
<td>@weatherForecast.TemperatureC</td>
<td>@weatherForecast.TemperatureF</td>
<td>@weatherForecast.Summary</td>
</tr>
}
</tbody>
</table>
That brings us to the second challenge. If the await foreach
can be used only in the code block, how the streamed results can be rendered as they come? Here the solution comes in form of the StateHasChanged method. Calling this method will trigger a render. With this knowledge, we can adopt the DeserializeAsyncEnumerable
based code from my previous post.
@code {
private List<WeatherForecast> weatherForecasts = new List<WeatherForecast>();
private async Task StreamWeatherForecastsJson()
{
weatherForecasts = new List<WeatherForecast>();
StateHasChanged();
using HttpResponseMessage response = await Http.GetAsync("api/WeatherForecasts/negotiate-stream", HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
using Stream responseStream = await response.Content.ReadAsStreamAsync();
await foreach (WeatherForecast weatherForecast in JsonSerializer.DeserializeAsyncEnumerable<WeatherForecast>(
responseStream,
new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
DefaultBufferSize = 128
}))
{
weatherForecasts.Add(weatherForecast);
StateHasChanged();
}
}
}
Running that code put me in the exact same spot where the person asking the question was. All the results were rendered at once after the entire wait time. What to do, when you have no idea what might be wrong and where? Dump what you can to the console ;). No, I'm serious. Debugging through console.log
is in fact quite useful in many situations and I'm not ashamed of using it here. I've decided that the diagnostic version will perform direct response stream reading.
@code {
...
private async Task StreamWeatherForecastsJson()
{
Console.WriteLine($"-- {nameof(StreamWeatherForecastsJson)} --");
Console.WriteLine($"[{DateTime.UtcNow:hh:mm:ss.fff}] Requesting weather forecasts . . .");
using HttpResponseMessage response = await Http.GetAsync("api/WeatherForecasts/negotiate-stream", HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
Console.WriteLine($"[{DateTime.UtcNow:hh:mm:ss.fff}] Receving weather forecasts . . .");
using Stream responseStream = await response.Content.ReadAsStreamAsync();
Console.WriteLine($"[{DateTime.UtcNow:hh:mm:ss.fff}] Weather forecasts stream obtained . . .");
while (true)
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(128);
int bytesRead = await responseStream.ReadAsync(buffer);
Console.WriteLine($"[{DateTime.UtcNow:hh:mm:ss.fff}] ({bytesRead}/{buffer.Length}) {Encoding.UTF8.GetString(buffer[0..bytesRead])}");
ArrayPool<byte>.Shared.Return(buffer);
if (bytesRead == 0)
{
break;
}
}
Console.WriteLine($"[{DateTime.UtcNow:hh:mm:ss.fff}] Weather forecasts has been received.");
Console.WriteLine();
}
}
Below you can see the output from browser developer tools.
-- StreamWeatherForecastsJson --
[08:04:01.183] Requesting weather forecasts . . .
[08:04:01.436] Receving weather forecasts . . .
[08:04:02.420] Weather forecasts stream obtained . . .
[08:04:02.426] (128/128) [{"dateFormatted":"06.12.2021","temperatureC":28,"temperatureF":82,"summary":"Hot"},{"dateFormatted":"07.12.2021","temperatureC"
[08:04:02.429] (128/128) :36,"temperatureF":96,"summary":"Scorching"},{"dateFormatted":"08.12.2021","temperatureC":-7,"temperatureF":20,"summary":"Mild"}
[08:04:02.431] (128/128) ,{"dateFormatted":"09.12.2021","temperatureC":-6,"temperatureF":22,"summary":"Hot"},{"dateFormatted":"10.12.2021","temperatureC"
[08:04:02.433] (128/128) :40,"temperatureF":103,"summary":"Cool"},{"dateFormatted":"11.12.2021","temperatureC":44,"temperatureF":111,"summary":"Swelterin
[08:04:02.434] (128/128) g"},{"dateFormatted":"12.12.2021","temperatureC":-3,"temperatureF":27,"summary":"Balmy"},{"dateFormatted":"13.12.2021","temperat
[08:04:02.435] (128/128) ureC":1,"temperatureF":33,"summary":"Sweltering"},{"dateFormatted":"14.12.2021","temperatureC":3,"temperatureF":37,"summary":"Ho
[08:04:02.437] (88/128) t"},{"dateFormatted":"15.12.2021","temperatureC":19,"temperatureF":66,"summary":"Mild"}]tureC":3,"temperatureF":37,"summary":"Ho
[08:04:02.438] (0/128) t"},{"dateFormatted":"15.12.2021","temperatureC":19,"temperatureF":66,"summary":"Mild"}]tureC":3,"temperatureF":37,"summary":"Ho
[08:04:02.439] Weather forecasts has been received.
So the call which is blocking the whole thing seems to be ReadAsStreamAsync
, when it returns the entire response is already available. All I knew at this point was that Blazor WebAssembly is using a special HttpMessageHandler
. I needed to dig deeper.
Digging Into BrowserHttpHandler
There is a number of things that have dedicated implementations for Blazor WebAssembly. The HttpClient
stack is one of those things. Well, there is no access to native sockets in the browser, so the HTTP calls must be performed based on browser-provided APIs. The BrowserHttpHandler
is implemented on top of Fetch API. Inspecting its code shows that it can provide response content in one of two forms.
The first one is BrowserHttpContent
, which is based on arrayBuffer
method. This means, that it will always read the response stream to its completion, before making the content available.
The second one is StreamContent
wrapping WasmHttpReadStream
, which is based on readable streams. This one allows for reading response as it comes.
How does BrowserHttpHandler
decide which one to use? In order for WasmHttpReadStream
to be used, two conditions must be met - the browser must support readable streams and the WebAssemblyEnableStreamingResponse
option must be enabled on the request. Now we are getting somewhere. Further search for WebAssemblyEnableStreamingResponse
will reveal a SetBrowserResponseStreamingEnabled
extension method. Let's see what happens if it's used.
@code {
...
private async Task StreamWeatherForecastsJson()
{
Console.WriteLine($"-- {nameof(StreamWeatherForecastsJson)} --");
Console.WriteLine($"[{DateTime.UtcNow:hh:mm:ss.fff}] Requesting weather forecasts . . .");
HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, "api/WeatherForecasts/negotiate-stream");
request.SetBrowserResponseStreamingEnabled(true);
using HttpResponseMessage response = await Http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
...
}
}
This gives the desired output.
-- StreamWeatherForecastsJson --
[08:53:14.722] Requesting weather forecasts . . .
[08:53:15.002] Receving weather forecasts . . .
[08:53:15.009] Weather forecasts stream obtained . . .
[08:53:15.018] (84/128) [{"dateFormatted":"06.12.2021","temperatureC":31,"temperatureF":87,"summary":"Cool"}
[08:53:15.057] (84/128) ,{"dateFormatted":"07.12.2021","temperatureC":18,"temperatureF":64,"summary":"Cool"}
[08:53:15.166] (86/128) ,{"dateFormatted":"08.12.2021","temperatureC":10,"temperatureF":49,"summary":"Chilly"}
[08:53:15.276] (84/128) ,{"dateFormatted":"09.12.2021","temperatureC":33,"temperatureF":91,"summary":"Mild"}
[08:53:15.386] (88/128) ,{"dateFormatted":"10.12.2021","temperatureC":-14,"temperatureF":7,"summary":"Freezing"}
[08:53:15.492] (84/128) ,{"dateFormatted":"11.12.2021","temperatureC":12,"temperatureF":53,"summary":"Warm"}
[08:53:15.600] (86/128) ,{"dateFormatted":"12.12.2021","temperatureC":6,"temperatureF":42,"summary":"Bracing"}
[08:53:15.710] (85/128) ,{"dateFormatted":"13.12.2021","temperatureC":48,"temperatureF":118,"summary":"Mild"}
[08:53:15.818] (89/128) ,{"dateFormatted":"14.12.2021","temperatureC":13,"temperatureF":55,"summary":"Scorching"}
[08:53:15.931] (88/128) ,{"dateFormatted":"15.12.2021","temperatureC":44,"temperatureF":111,"summary":"Chilly"}]
[08:53:15.943] (0/128)
[08:53:15.946] Weather forecasts has been received.
Corrected Implementation
This means, that in order to have stream-based access to the response body (regardless if it's JSON or something else), one needs to explicitly enable it on the request. So the code which is able to receive async streamed JSON and properly deserialize it to IAsyncEnumerable
should look like this.
@code {
...
private async Task StreamWeatherForecastsJson()
{
weatherForecasts = new List<WeatherForecast>();
StateHasChanged();
HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, "api/WeatherForecasts/negotiate-stream");
request.SetBrowserResponseStreamingEnabled(true);
using HttpResponseMessage response = await Http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
using Stream responseStream = await response.Content.ReadAsStreamAsync();
await foreach (WeatherForecast weatherForecast in JsonSerializer.DeserializeAsyncEnumerable<WeatherForecast>(
responseStream,
new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
DefaultBufferSize = 128
}))
{
weatherForecasts.Add(weatherForecast);
StateHasChanged();
}
}
}
This works exactly as expected - the results are being rendered as they are being returned from the backend (with precision resulting from the size of the buffer).
Lesson Learned
Despite the fact that there is no dedicated TFM for Blazor WebAssembly, and it uses the "runs everywhere" one (net5.0
, net6.0
, etc.) there are platform differences. You will notice the APIs which aren't supported very quickly because they will throw PlatformNotSupportedException
. But there are also more sneaky ones, ones which behavior is different. Blazor WebAssembly needs to be approached with this thought in the back of your mind. This will allow you to properly handle situations when things are not working the way you've expected.