Blazier, lazy Blazor WebAssembly for static hosts

Show the page now. Download .NET when they press Start.

Blazier turns your Blazor WebAssembly pages into real HTML at publish time. Visitors read at once, and the .NET runtime downloads only when someone presses the button that needs it. Long C# jobs run in a Web Worker behind an ordinary interface.

dotnet add package EvaluatedApplications.Blazier

Commercial licence for commercial use. Evaluation and non-commercial use are permitted.Licence terms

Try it now, in your browser

Every tool on this site is a Blazier page

Open any tool. Its page reads at once, and its roughly 11 MB runtime downloads only when you press Start. Scrolling, tapping text or following a link downloads nothing.

What the visitor waits for

One app, booted two ways. Before anyone acts, the static shell downloads 6.8 KB against 2.8 MB. Pick a view to see real content on screen and the time until the page works.

Downloaded before the visitor does anything, usual boot against Blazier static shellBefore the visitor does anything, the Blazier static shell downloads 6.8 KB in 6 requests. The same app booted the usual way downloads 2.8 MB.0123Usual boot2.8 MBBlazier6.8 KBMB, linear scale; the lighter end of a bar is the slowest recorded run
Data behind the diagram
Downloaded before the visitor does anything
ProfileBooted the usual wayBlazier static shell
Page2.8 MB6.8 KB
Until real content is on screen
ProfileBooted the usual wayBlazier static shell
Desktop748 to 1,165 ms72 to 100 ms
Phone profile13.2 to 15.5 s416 to 484 ms
Until the page works. The usual boot is timed from loading the page. Blazier is timed from pressing Start, with the page already readable
ProfileBooted the usual wayBlazier static shell
Desktop748 to 1,165 ms710 to 1,170 ms
Phone profile13.2 to 15.5 s12.7 to 15.4 s
Worker bridge, desktop
MeasureRecorded
Start a worker runtime from cached files190 to 210 ms
One call to a worker service, round trip0.49 to 0.57 ms
Worst gap on the page's timers during a long job14 to 19 ms
Long tasks on the page during that job0
Stop a job that yields12 to 17 ms
Stop a job that never yields, until the worker is usable again730 to 800 ms
Restart the worker170 to 180 ms
Stream 2,000 items, first item16 to 21 ms
Stream 2,000 items, all of them250 to 272 ms
Memory of a worker runtime32 MiB
Worker start on a shared bundle132 requests, all served from the browser cache (0 KB over the network)
Recorded on the sample app that ships with Blazier (release 1.1.0) in headless Edge. Desktop is unthrottled and the phone profile is 4 Mbit/s, 100 ms round trip, CPU slowed 6 times. Every bar and every figure on this page is read from that recording.

The numbers

downloaded before the visitor does anything, against 2.8 MB when the same app boots the usual way
6.8 KB
until real content is on screen on the phone profile, against 13.2 to 15.5 s booting the usual way
416 to 484 ms
worst gap on the page's timers while a worker ran a long job, with 0 long tasks
14 to 19 ms
for one call to a worker service, round trip
0.49 to 0.57 ms
See the numbers
  • 6.8 KB before anyone acts

    On the sample app the static page, its loader and its styles come to 6.8 KB in 6 requests. The same app booted the usual way downloads 2.8 MB before the visitor can do anything.

  • Content on screen in 416 to 484 ms on a phone profile

    Real content is on screen in 72 to 100 ms on desktop. Booted the usual way it takes 748 to 1,165 ms on desktop and 13.2 to 15.5 s on the phone profile (4 Mbit/s, 100 ms round trip, CPU slowed 6 times).

  • The runtime still costs what it costs

    From pressing Start to a working page takes 710 to 1,170 ms on desktop and 12.7 to 15.4 s on the phone profile, the same wait as the usual boot. Blazier moves it behind a readable page instead of in front of it.

  • A worker that does not freeze the page

    A worker runtime starts in 190 to 210 ms from cached files and a call takes 0.49 to 0.57 ms. While a worker ran a prime count to 600,000, the worst gap on the page's timers was 14 to 19 ms, with 0 long tasks. A job that yields stops in 12 to 17 ms.

  • Where it was measured

    The sample app, in headless Edge on an Intel Core i9-11950H laptop with 32 GB of memory. Desktop is unthrottled (3 runs) and the phone profile is 4 Mbit/s, 100 ms round trip, CPU slowed 6 times (2 runs). A range runs from the fastest to the slowest run.

Release 1.1.0Stands alone, no other package · Typed Web Worker bridge · .NET 10

What you can build

Each one is in the package today.
  • Pages that read at once on a static host

    Mark a component with the BlazierPage attribute; publish writes its HTML into your own index.html. Titles, descriptions, canonical and Open Graph tags come with it, so the page is indexable and previews well when shared.

  • Tools that cost nothing until they are used

    Put a Start button on the page and only a click on that button downloads the runtime. Scrolling, tapping text or following a link never does, so a phone visitor who only reads pays for the HTML and nothing else.

  • Pages made of independent islands

    A chart here, a form there, each booting the one shared runtime on its own trigger: click, scroll into view, browser idle, or straight away.

  • Heavy components that download on demand

    Put a component in a lazy assembly and its code arrives only when its island is used. The build fails and names the path if startup code would reach it, instead of the app dying at boot in a visitor's browser.

  • Long C# jobs that never freeze the page

    Write an ordinary interface; a generated bridge runs it in a Web Worker. The page awaits the call, shows progress, receives results as a stream and can cancel.

  • Data pages that still prerender

    Components that fetch data render a skeleton in the static page. A page that tries to fetch at publish fails the build instead of shipping an error message as HTML.

Run it yourself

A page that reads before any .NET code runs, then a long job that never freezes it.

Start from a Blazor WebAssembly project and turn on scaffolding in the project file.

Project file
<PropertyGroup>
  <BlazierScaffold>true</BlazierScaffold>
</PropertyGroup>

Load the loader in wwwroot/index.html instead of starting Blazor yourself. Keep the import map line.

wwwroot/index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Hello</title>
    <base href="/" />
    <script type="importmap"></script>
</head>
<body>
    <div id="app" data-blazier-island="app" data-blazier-start="immediate">Loading...</div>
    <script src="_content/EvaluatedApplications.Blazier/blazier.js" defer></script>
</body>
</html>

Register your root component. HelloApp stands for your project's root namespace.

Program.cs
using Blazier;
using HelloApp;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;

var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.UseBlazier(b => b.App<App>());
await builder.Build().RunAsync();

Mark the page. The button is the only thing that starts the runtime.

Pages/Hello.razor
@page "/hello"
@using Blazier
@attribute [BlazierPage("hello", Start = StartOn.Click, ClickTarget = "[data-blazier-go]",
    Title = "Hello", Description = "A page that is readable before any .NET code runs.")]
@inject IBuildTime Build

<h1>Hello</h1>
<p>This text is plain HTML. Reading it downloads no .NET runtime.</p>
<button type="button" data-blazier-go>Start</button>
@if (!Build.IsPrerender)
{
    <p>The runtime is running now.</p>
}

dotnet publish writes hello/index.html with the heading, text and button already in it. The runtime downloads when the button is pressed, then the live component replaces the shell.

hello/index.html after dotnet publish (body)
<div id="app" data-blazier-island="app" data-blazier-start="click" data-blazier-click-target="[data-blazier-go]"><h1>Hello</h1>
<p>This text is plain HTML. Reading it downloads no .NET runtime.</p>
<button type="button" data-blazier-go>Start</button></div>
<script src="_content/EvaluatedApplications.Blazier/blazier.js" defer></script>

A long job in a worker, behind an ordinary interface. The page awaits the call, shows progress and can cancel.

A worker service
using Blazier.Workers;

[WorkerService]
public interface IPrimes
{
    Task<int> CountAsync(int limit, IProgress<double> progress, CancellationToken cancellationToken);
}

public sealed class Primes : IPrimes
{
    public async Task<int> CountAsync(int limit, IProgress<double> progress, CancellationToken cancellationToken)
    {
        var slice = new CooperativeSlice();   // yields about every 12 ms so a cancel message can be read
        int count = 0;
        for (int n = 2; n <= limit; n++)
        {
            bool prime = true;
            for (int d = 2; d * d <= n; d++) if (n % d == 0) { prime = false; break; }
            if (prime) count++;
            if (slice.IsDue)
            {
                progress.Report((double)n / limit);
                await slice.YieldIfDueAsync(cancellationToken);
            }
        }
        return count;
    }
}

public static class PrimeDemo
{
    // Call from a button handler: the work runs in a Web Worker and the page stays responsive.
    public static async Task<int> RunAsync(Action<double> onProgress, CancellationToken cancellationToken)
    {
        await using var worker = await BlazierWorker.StartAsync();
        return await worker.Get<IPrimes>().CountAsync(5_000_000, new Progress<double>(onProgress), cancellationToken);
    }
}

The page project and the worker program both build against the published package. The block under hello/index.html is the body that dotnet publish wrote for this page. Running the worker needs a browser.

How it works

Blazier is a publish step, a small loader and a worker bridge in one package. It hosts any Blazor WebAssembly app and needs no other Evaluated Applications package.

  • Publish renders your pages

    After dotnet publish, a small tool renders every BlazierPage component with Blazor's HtmlRenderer and writes it into your own index.html as path/index.html. Components see IBuildTime.IsPrerender as true while this happens.

  • A tiny loader waits for intent

    blazier.js is a classic script with no dependencies. On click (anywhere in the page or only on one chosen control), visibility, idle or immediately, it starts Blazor and mounts the component over the shell, keeping the shell on screen until the live one has rendered. A handshake from Blazor ties runtime start and mounting together, so mounting does not race the runtime.

  • Islands are named components

    Register them in UseBlazier; mark them in Razor with Island or in plain HTML with data-blazier-island. Each element reports its state (idle, booting, mounting, live, failed) for styling, and the page raises blazier events with byte and timing figures.

  • Lazy assemblies are named in the project file

    LazyIsland("chart", "Charts.PrimeChart, Charts", "Charts") mounts a component from an assembly that downloads on demand. After each build a guard walks the code reachable from startup and fails the build, naming the path, if it reaches a lazy-assembly type.

  • The worker bridge is generated

    A source generator turns a WorkerService interface into a page-side proxy and a worker-side dispatcher. The worker starts a second runtime from the files the page already downloaded. Calls, progress and streamed items are messages; arguments and results travel as JSON. A cancellation token cancels cooperatively. A job that never yields is stopped by terminating and restarting the worker after a grace period, and only that worker's calls fail.

Everything in the package

  • Per-page head tags

    Title and Description on the BlazierPage attribute replace the template's title and meta description in each shell. Set the BlazierSiteUrl property and every shell also gets a canonical link and Open Graph tags.

  • On-intent loader

    Boots Blazor on click, visibility, idle or immediately, keeps the static shell visible until the component has rendered, and mounts islands on a ready handshake from the runtime.

  • Start on one control

    Give a Click page a ClickTarget selector and only a click on that control starts the runtime, so a phone visitor who scrolls or taps text never downloads it.

  • Islands

    Several named components on one static page, each booting the one shared runtime on its own trigger. Island marks them in Razor, and data-blazier-island works in plain HTML.

  • Build-time shells for pages that fetch

    Inject IBuildTime and skip fetching when IsPrerender is true, or wrap the live part in NotAtBuildTime with a skeleton. A page that still tries to fetch fails the publish with BLZ102 instead of shipping an error message as static HTML.

  • Lazy assemblies with a guard

    LazyIsland mounts a component whose assembly downloads only on demand. After every build a guard fails with BLZ001, naming the path, if startup code reaches a lazy-assembly type.

  • Typed worker bridge

    Calls are awaitable, IProgress streams progress, IAsyncEnumerable streams results with backpressure, and a CancellationToken cancels cooperatively. A job that never yields is stopped by terminating and restarting the worker after a grace period.

  • Same protocol in-process

    BlazierWorker.StartInProcessAsync runs your services on a dedicated thread with the same messages, for unit tests and non-browser hosts.

What exists today

Built and measured, and not built
  • Typed Web Worker bridge Built and measured

    Awaitable calls, IProgress, IAsyncEnumerable with backpressure, cooperative and hard cancellation, restart, and an in-process mode that speaks the same protocol for unit tests. It has been run in headless Edge against the sample app. It has not been run in Firefox or Safari, and its phone memory and battery cost are not measured. Arguments are JSON text; there is no binary or zero-copy message.

  • EvalApp host seam Not built

    Blazier has no EvalApp dependency and no EvalApp-specific code. Nothing fans an EvalApp pipeline out across several worker runtimes. You can run a whole pipeline inside one worker service today by writing that service yourself; a built-in pool of workers behind EvalApp does not exist.

When another tool fits better

  • A page that always needs the runtime, a dashboard behind a login for example, gains little from a static shell. Plain Blazor WebAssembly is simpler.
  • Content that needs no .NET in the browser at all is better served by a static site generator.
  • Server-rendered interactivity (Blazor Server or Blazor Web App) suits apps that can run a server. Blazier is for hosts with none, such as GitHub Pages.
  • Parallel speed-ups across cores. Workers free the page; they do not share memory. Each worker is its own runtime with its own copy of your data.

Know before you start

What Blazier does not do, and where to be careful
  • The first interaction starts the runtime but is not replayed: the visitor presses again once the page is live.
  • Moving between two static pages is a full page load, so in-memory state does not carry over. Mount the routed app (App<TApp>()) to keep one runtime across tools.
  • The lazy guard is conservative: it follows startup code, routable pages and component render code, not lambdas that run later (event handlers, DI factories).
  • Lazy assemblies save little in an ahead-of-time compiled build, because that native code lives in the main runtime file either way. They pay off with the interpreter build.
  • A worker runtime starts at about 32 MiB of memory; on phones, memory is the budget. The worker bridge was measured with a shared bundle on the interpreter build. A separate worker bundle exists but has not been run in a browser.
  • Requires .NET 10 and the Blazor WebAssembly package matching the installed browser-wasm runtime pack. The publish step runs a small tool that needs the ASP.NET Core runtime.

Every public type is documented in the package readme on NuGet. Open the NuGet page.

On the sample app the static page downloads 6.8 KB before the visitor acts, against 2.8 MB when the same app boots the usual way. Real content is on screen in 72 to 100 ms on desktop and 416 to 484 ms on the phone profile, against 748 to 1,165 ms and 13.2 to 15.5 s. After the visitor presses Start the runtime takes 710 to 1,170 ms on desktop and 12.7 to 15.4 s on the phone profile to reach a working page, the same wait as the usual boot.

Licence

Proprietary. Every package ships as a compiled library. Evaluation and non-commercial development or testing are permitted; commercial, production or revenue-generating use needs a commercial licence from the owner. Read the licence terms.