Micro Frontends With ASP.NET Core and Blazor WebAssembly Components

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 the form of the State Has Changed method. Calling this method will trigger a render. With this knowledge, we can adopt the Deserialize Async Enumerable-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}] Receiving 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 have 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] Receiving 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":36,"temperatureF":96,"summary":"Scorching"},{"dateFormatted":"08.12.2021","temperatureC":-7,"temperatureF":20,"summary":"Mild"}[08:04:02.429] (128/128) ,{"dateFormatted":"09.12.2021","temperatureC":-6,"temperatureF":22,"summary":"Hot"},{"dateFormatted":"10.12.2021","temperatureC":40,"temperatureF":103,"summary":"Cool"},{"dateFormatted":"11.12.2021","temperatureC":44,"temperatureF":111,"summary":"Sweltering"},{"dateFormatted":"12.12.2021","temperatureC":-3,"temperatureF":27,"summary":"Balmy"},{"dateFormatted":"13.12.2021","temperatureC":1,"temperatureF":33,"summary":"Sweltering"},{"dateFormatted":"14.12.2021","temperatureC":3,"temperatureF":37,"summary":"Hot"},{"dateFormatted":"15.12.2021","temperatureC":19,"temperatureF":66,"summary":"Mild"}]
[08:04:02.437] (0/128) t"},{"dateFormatted":"15.12.2021","temperatureC":19,"temperatureF":66,"summary":"Mild"}]
[08:04:02.439] Weather forecasts have been received.

So the call that 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 are 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 Browser Http Handler is implemented on top of the Fetch API. Inspecting its code shows that it can provide response content in one of two forms.

The first one is Browser Http Content, which is based on the arrayBuffer method. This means, that it will always read the response stream to its completion, before making the content available.

The second one is Stream Content wrapping WasmHttpReadStream, which is based on readable streams. This one allows for reading response as it comes.

How does Browser Http Handler 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 Web Assembly Enable Streaming Response option must be enabled on the request. Now we are getting somewhere. Further search for Web Assembly Enable Streaming Response will reveal a Set Browser Response Streaming Enabled 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] Receiving 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 have 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 that 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.