Blast Off with Blazor: Prerender a Blazor Web Assembly application

So far in our series, we’ve walked through the intro, wrote our first component, dynamically updated the HTML head from a component, isolated our service dependencies, worked on hosting our images over Azure Blob Storage and Cosmos DB, and built a responsive image gallery.

I did some housekeeping with the code that I won’t mention here, such as renaming projects. Check out the GitHub repo for the latest.

Now, we need to discuss how to mitigate the biggest drawback of using Blazor Web Assembly: the initial load time. While the Web Assembly solution allows us to enjoy a serverless deployment scenario and fast interactions, the initial download can take awhile. Users need to sit around while the browser downloads the .NET runtime and all your project’s DLLs.

While the ASP.NET Core team has made a lot of progress in improving the .NET IL linker and making framework libraries more linkable, the initial startup time of Blazor Web Assembly apps is still quite noticeable. In my case, an initial, uncached download takes 7 seconds. With prerendering, I was able to cut this down considerably. That is the focus of this post.

With ahead-of-time (AoT) compilation coming with .NET 6, you might be inclined to treat it as a fix to this problem. AoT will not help decrease the app download size because Web Assembly uses an Intermediate Language (IL) interpreter and isn’t a JIT-based runtime. In terms of performance, AoT can speed up runtime performance but will mean an even larger download size. See Dan Roth’s comment for details.

This post covers the following content.

This post builds off the wonderful content from Chris Sainty, Jon Hilton, and the ASP.NET Core documentation.

What is prerendering?

Without prerendering, your Blazor Web Assembly app parses the index.html file it receives, then fetches all your dependencies. Then, it’ll load and launch your application—leaving you with a less-than-ideal UX as users are sitting and waiting. With prerendering, the initial load occurs on the server with the download occurring in the background. By the time the page quickly loads and the download completes, your users are ready to interact with your site—with a big Blazor Web Assembly drawback out of the way.

When we say prerendering, we really mean prerendering components. We do this by integrating our app using Razor Pages. The cost here: you’re kissing goodbye to a simple static file deployment in favor of faster startup times.

This is not simply converting your app to a Blazor Server solution. While Blazor Server executes your application from a server-side ASP.NET Core app, this is compiling your code and serving static assets from a server project. You’re still running your app in a browser as you were before.

To get started, we need to create and configure a Server project.

Configure the BlastOff.Server project

To get started, you’ll need to create a new BlastOff.Server project and add it to the solution. The easiest and simplest way is using the dotnet CLI.

To create a new project, I executed the following from the root of the project (where the solution file is):

dotnet new web -o BlastOff.Server

Then, I added it to my existing solution:

dotnet sln add BlastOff.Server/BlastOff.Server.csproj

In the new BlastOff.Server project, I headed over to Startup.cs and updated the Configure method to the following:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    public void Configure(IApplicationBuilder app)
    {
        // some code removed for brevity

        app.UseBlazorFrameworkFiles();
        app.UseStaticFiles();

        app.UseRouting();

        app.UseEndpoints(endpoints =>
        {
            endpoints.MapRazorPages();
            endpoints.MapControllers();
            endpoints.MapFallbackToPage("/_Host");
        });
    }
}

With the IApplicationBuilder interface, we’ll call UseBlazorFrameworkFiles() that will allow us to serve Blazor Web Assembly files from a specified path. The UseStaticFiles() call enables us to serve static files.

After enabling routing, we use the endpoint middleware to work with Razor Pages, which is the secret sauce behind this magic. Then, in MapFallbackToPage(), we’re serving a /_Host.cshtml file in the Server project’s Pages directory.

This _Host.cshtml file will replace our Client project’s index.html file. Therefore, we can copy the contents of our index.html here.

You’ll notice below that we:

  • Set the namespace to BlastOff.Server.Pages
  • Set a @using directive to the BlastOff.Client project
  • Make sure the <link href> references resolve correctly
  • Update the Component Tag Helper to include a render-mode of WebAssemblyPrerendered. This option prerenders the component into static HTML and includes a flag for the app to make the component interactive when the browser loads it
  • Ensure the Blazor script points to the client script at _framework/blazor.webassembly.js

Here’s the _Host.cshtml file in its entirety:

@page "/"
@namespace BlastOff.Server.Pages
@using BlastOff.Client

<html>
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Blast Off With Blazor</title>
    <base href="~/" />
    <link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" />
    <link href="css/site.css" rel="stylesheet" />
</head>
<body>
    <app>
        <component type="typeof(App)" render-mode="WebAssemblyPrerendered" />
    </app>

    <script src="_framework/blazor.webassembly.js"></script>
</body>
</html>

With this in place, I headed over to the BlastOff.Client project to delete wwwroot/index.html and this line from Program.Main. It felt both nice and weird.

builder.RootComponents.Add<App>("#app");

Share the service layer

With prerendering, we need to think about how our calls to Azure Functions work. Previously, our API calls were from the BlastOff.Client project. With prerendering, it’ll first need to make these calls from the server. As a result, it’ll make better sense to move our service layer out of the BlastOff.Client project and to a new BlastOff.Shared project.

Here’s our ImageService now (I also moved the Image model here for good measure):

using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading.Tasks;

namespace BlastOff.Shared
{
    public interface IImageService
    {
        public Task<IEnumerable<Image>> GetImages(int days);
    }

    public class ImageService : IImageService
    {
        readonly IHttpClientFactory _clientFactory;
        readonly ILogger<ImageService> _logger;

        public ImageService(ILogger<ImageService> logger, IHttpClientFactory clientFactory)
        {
            _clientFactory = clientFactory;
            _logger = logger;
        }

        public async Task<IEnumerable<Image>> GetImages(int days)
        {
            try
            {
                var client = _clientFactory.CreateClient("imageofday");
                var images = await client.GetFromJsonAsync
                    <IEnumerable<Image>>($"api/image?days={days}");
                return images.OrderByDescending(img => img.Date);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex.Message, ex);
            }

            return default;
        }
    }
}

We can thank ourselves for isolating our service dependency a few posts ago–turning a potential headache to some copying and pasting. The only cleanup in our BlastOff.Client project is to bring in the BlastOff.Shared project to reference our service in Program.cs:

using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.Extensions.DependencyInjection;
using BlastOff.Shared;

namespace BlastOff.Client
{
    public class Program
    {
        public static async Task Main(string[] args)
        {
            var builder = WebAssemblyHostBuilder.CreateDefault(args);
            builder.Services.AddHttpClient("imageofday", iod =>
            {
                iod.BaseAddress = new Uri("http://localhost:7071" ?? builder.HostEnvironment.BaseAddress);
            });
            builder.Services.AddScoped<IImageService, ImageService>();

            await builder.Build().RunAsync();
        }
    }
}

If we run the app, it loads much quicker. When we browse to /images it loads fast too. But if we hit reload, we’ll see a nasty error because we didn’t register the ImageService in our Server project. In the DI container logic in Startup.ConfigureServices, then, add the HttpClient and ImageService references:

public void ConfigureServices(IServiceCollection services)
{
    services.AddRazorPages();

    services.AddHttpClient("imageofday", iod =>
    {
        iod.BaseAddress = new Uri("http://localhost:7071");
    });

    services.AddScoped<IImageService, ImageService>();
}

Check our progress

Check out how much better everything looks now. Much better.

Wrap up

In this post, we worked through how to prerender our Blazor Web Assembly application. We created a new server project, shared our dependencies in a new project, and understood how prerendering can significantly improve initial load times.

References