---
title: "Productionizing Our Blazor WebAssembly Editor"
description: "How HCTI handles runtime performance, download size, trimming, loading experience, and browser testing for a production Blazor WASM editor."
published: "2026-07-14"
author: "Jeffrey Needles"
canonical: "https://htmlcsstoimage.com/blog/productionizing-blazor-wasm"
---


The HCTI template editor is a substantial Blazor WebAssembly application running inside our otherwise server-rendered site. Getting it working locally was still a long way from having a production editor. It had to remain responsive under constant input, ship a manageable download, survive release trimming, provide a loading experience that respects the wait and stay testable across both editor and renderer behavior.

In Blazor WebAssembly, the browser downloads a .NET runtime, the application's assemblies and its other assets, then runs the C# client locally. That gives us a real client application, along with a startup cost that a server-rendered page does not have.

That work reaches into nearly every layer. Runtime ownership determines which work belongs in C# or TypeScript. Component boundaries determine how much Blazor rerenders. The host page determines what users see while WASM boots. The test suite has to catch model and CSS mistakes before paying the cost of a browser render.

This post covers the work around shipping the editor rather than building one individual feature: runtime performance, download size, interop trim roots, startup preloading, the loading shell and local Chromium tests.

::: card
#### Related Reading

The [architecture overview](/blog/building-a-visual-template-editor-in-blazor-wasm) explains where the WASM client sits in the larger product. Separate posts cover [Blazor and TypeScript interactions](/blog/blazor-wasm-typescript-interop), [CSS generation](/blog/generating-complex-css), and [MemoryPack storage and schema evolution](/blog/memorypack-is-awesome).
:::

## Performance

Blazor WASM has a bad reputation for performance. Some of that is earned, and some of it gets repeated long after the details have changed. For this editor, I mostly split performance into two questions:

1. **Is the editor responsive once it is running?**
2. **Is the download and startup cost acceptable?**

Those are related, but they are not the same problem.

### Runtime Performance

At runtime, Blazor is great for the structured UI: property panels, selection state, validation messages, variable configuration, undo/redo state and the general editor shell. It is not where I wanted to run pointer math for every mouse movement while also measuring DOM geometry and handling scroll offsets.

The TypeScript split works well here. High-frequency pointer work happens close to the browser, and the commit comes back to .NET as a small, intentional update. C# owns the committed model, but it is not asked to process every pixel of a drag.

The generated editing layer avoids another class of runtime work. A generic color input sends an address and a value, then generated code routes it without reflection, expression parsing or boxing everything into `object`. CSS output follows generated emitters instead of discovering attributes at runtime.

The stylesheet components are also narrow about when they render. Each one owns the CSS for a single block and caches the normal model version, preview version and variable-context version. Its preview notification handler returns immediately when none of those changed:

```csharp
// Shortened for readability.

private void HandlePreviewUpdates()
{
    if (_renderPending || !ViewContext.TryGetNode(BlockId, out var node))
    {
        return;
    }

    var variableVersion = ViewContext.VariablePreviewEnabled
        ? ViewContext.VariablePreviewRevision
        : -1;

    if (currentState.Version == node.Version &&
        currentState.PreviewVersion == node.PreviewVersion &&
        currentState.VariableCtxVersion == variableVersion)
    {
        return;
    }

    _renderPending = true;
    _ = InvokeAsync(async () =>
    {
        await Task.Yield();
        _renderPending = false;
        StateHasChanged();
    });
}
```

> [!TIP]
> The `Task.Yield()` is deliberate. `_renderPending` is set before the work is scheduled and remains true while the callback yields. Any other preview notifications that arrive before the continuation runs hit the first guard and return, so a burst of changes queues one stylesheet render instead of many. The discard is intentional too: the notification handler does not need to wait for the render, it only needs to make sure one is queued on Blazor's renderer context.

That `StateHasChanged()` belongs to the small `BlockStylesheet` component, not the block's markup component. After the style element changes, the browser's CSS engine handles the existing block DOM.

There is also smaller C# work that is not very glamorous, but pays off because it is everywhere. CSS and HTML generation use spans, UTF-8 builders and precomputed byte values where it makes sense. Value types like sizes and colors parse from `ReadOnlySpan<char>` and format into spans. ZLinq and ZString help keep common traversal and string-building code from allocating more than it needs to.

None of that replaces normal profiling, and it is easy to overdo. Most of our useful wins came from making a render, allocation or interop call unnecessary, not shaving a few instructions after the work already existed.

### Download Size

The download is the part where Blazor WASM is hardest to ignore. It is heavier than a normal server-rendered page. There is a runtime, assemblies and the application code itself. You do not get to pretend that is free. Lazy loading assemblies is possible, but it is not free to wire up cleanly. Each .NET release makes the situation a little better: better trimming, better compression and better tooling around what actually ships.

The published app only stays manageable if you are strict about what enters the client. Release builds use trimming, invariant globalization, trimmer analysis and disabled debug/hot-reload pieces. Dependencies matter too. Every package has to earn its spot in the WASM app, and there is definitely still room for us to improve there.

The TypeScript side gets treated similarly. The editor interop, interaction runtime, rich text pieces and other browser-only code are bundled deliberately instead of turning into one giant pile. Some heavier pieces can be preloaded or split so the first useful editor render is not blocked by every possible tool the editor might eventually need.

### Trimming Without Breaking JS Interop

Trimming works by proving which code the application can reach and removing the rest. JavaScript interop makes that proof harder. A callback marked `[JSInvokable]` may have no normal C# caller, and a payload such as `InteractionRuntimeMoveCommitEvent` may only be constructed when Blazor deserializes an object sent from TypeScript.

That can produce an unpleasant category of bug: everything works in a development build, then a callback method, constructor or property needed for deserialization is missing from the trimmed release.

We keep the interop contracts explicit in a small trim-root class:

```csharp
// Shortened for readability.

internal static class TemplateEditorClientTrimRoots
{
    private const DynamicallyAccessedMemberTypes JsInteropContractMembers =
        DynamicallyAccessedMemberTypes.PublicConstructors |
        DynamicallyAccessedMemberTypes.PublicProperties;

    [DynamicDependency(
        DynamicallyAccessedMemberTypes.PublicMethods,
        typeof(TemplateEditorInteractionRuntimeCallbackBridge))]
    [DynamicDependency(
        JsInteropContractMembers,
        typeof(InteractionRuntimeCommitEvent))]
    [DynamicDependency(
        JsInteropContractMembers,
        typeof(InteractionRuntimeMoveCommitEvent))]
    [DynamicDependency(
        JsInteropContractMembers,
        typeof(InteractionRuntimeReparentFlexCommitEvent))]
    [DynamicDependency(
        nameof(BlockGeometryService.HandleBlockGeometryMeasurementsChanged),
        typeof(BlockGeometryService))]
    public static void Preserve()
    {
    }
}
```

`Program.cs` calls `TemplateEditorClientTrimRoots.Preserve()`. The method does not need to do anything. Its attributes tell the linker which members must survive. Our real list includes every JavaScript-created contract and callback used by interactions, geometry, Monaco, TipTap, popovers and the other interop modules.

This is intentionally explicit. It gives us one place to review when we add an interop payload, and it avoids disabling trimming for a much larger part of the client just to protect a handful of contracts. Trimmer warnings remain enabled as well, because a root list is useful protection but not a substitute for checking the published build.

### Loading Experience

Even with good trimming, the editor still has to download and boot. The loading state needs design work too.

The server-rendered page provides a normal element for the WASM app to mount into:

```html
<div id="hcti-template-editor"></div>
```

The Blazor client registers `EditorRoot` against that selector and takes over the element when it starts. The rest of the site can stay server-rendered and static where that makes sense, while the editor loads as a narrow WASM island inside the page. The host owns the boot scripts, loader skeleton, import maps, environment values and other boot-time configuration. The editor owns the editing experience and barely knows anything about the server-rendered page hosting it.

The host page renders a loader skeleton that uses the same basic shell as the editor: left sidebar, center canvas area and right panel. The editor mount point starts hidden. The boot script tracks resource loading, updates a percentage counter and traces load phases so we can see where time is going. When the editor has initialized enough to be useful, it calls back to the host page and the loader fades out over the real editor.

While the WASM pieces are downloading, the host page also starts a regular JavaScript fetch for the template itself. If you are opening a saved template version, that fetch can already be in flight before Blazor has started. When the editor comes alive, it asks the host for the preloaded bytes and deserializes them directly. If the preloaded response is not ready or fails, the editor falls back to a normal MagicOnion service call.

That may only save a small round trip, but small delays stack up during startup.

That does not make WASM smaller, but it does change how the wait feels. A blank page or generic loader exaggerates the wait. A shell that looks like the editor, shows real progress and fades into the finished UI feels like something is happening. It also prevents a jarring layout jump, because the loader already occupies the same slot the editor will take over.

## Testing

The editor crossed enough parts of the stack that our existing test approach was not sufficient on its own.

Most of HCTI already has end-to-end coverage against staging between deploys to staging and production. That works well for API behavior and final rendered images, but the editor needed more than "render this and compare the pixels." Many editor bugs are not obvious as a full-image diff. Sometimes the bug is that a `BlockInner` target is 2px too wide, a grid child is attached to the wrong wrapper, a preview value leaks into committed state or a generated address routes to the wrong nested property.

The generated block graph currently has about 180 addressable block properties, and that count treats a templatable value as one property rather than expanding it into fallback value, template key and requirement mode. Once you add panels, nested blocks, preview state, variables and CSS targets, testing every combination is not realistic.

We organize most tests around the contracts between those pieces instead of trying every possible template combination.

The main test layers ended up being:

- **Change tracking**: addresses route to the expected value, structural changes undo and redo correctly, preview does not become history and transactions stay grouped around user intent.
- **CSS production**: a known model produces the selectors, targets and declarations we expect.
- **Variables**: typed properties and Handlebars content merge into one variable inventory, collision rules work and fallbacks resolve correctly.
- **Model behavior**: typed values parse, validate, serialize and repair themselves after deserialization.
- **Services**: editor-specific API calls stay typed without having to exercise the whole browser UI for every case.

Most of those are plain .NET tests. They build a small `TemplateModel`, apply a path-addressed change, generate CSS, resolve variables or run validation, then assert on the model or string output. That is the boring test layer, but it is the layer that catches the most mistakes. If a generated path no longer reaches `GridLayout.Tracks.RowOverrides[k].Background.Gradient.Stops[i].Color`, I want to know that before a browser ever opens.

Browser tests still matter, just not for every single behavior. CSS has layout rules, and at some point Chromium is the only reliable source for the resolved geometry.

### Local Chromium

We have a few dozen local Chromium scenarios focused on the things that are hard to prove with pure string assertions: panel child behavior, root positioning, percent anchors, padding, dimensions, grid and flex layout, nested panels and target-specific box sizes.

We could have turned all of those into full image renders through the API and compared them to reference images. That would work, but it is not a great fit for what we are trying to prove. Pixel diffs are slower, noisier and often tell you only that something changed, not which layout contract broke. Staging is also more constrained than production, and rendering through the full API is not cheap enough to use as the first line of defense for every layout case.

We already had a lot of experience running Chromium through [PuppeteerSharp](https://github.com/hardkoded/puppeteer-sharp){target=_blank}, so we added a local path that still uses our normal `IBrowserService` and screenshot flow. A small custom image type carries a list of selectors and callbacks. Right before the screenshot step, the browser service measures each matching element and passes the result to its callback.

The complete scenario builder is intentionally substantial. It generates combinations across block types, dimensions, anchors, padding and parent layouts, then round-trips the model through MemoryPack before rendering it. The individual assertion is much easier to read. Here is one from the structured-grid checks:

```csharp
// Shortened for readability.

var wrapper = ResolveStructuredWrapperFrame(parentFrame, placement);

checks.Add(new Image_InjectingReadBefore.InjectedResult(
    block.Id,
    LayoutTarget.StructuredPanelChildWrapper,
    result =>
    {
        StandardChecks(result);
        var box = result.bounding_box!;

        wrapper.ContentX.Confirm((double)box.X, $"{label} wrapper x");
        wrapper.ContentY.Confirm((double)box.Y, $"{label} wrapper y");
        wrapper.ContentWidth.Confirm((double)box.Width, $"{label} wrapper width");
        wrapper.ContentHeight.Confirm((double)box.Height, $"{label} wrapper height");
    })
{
    Label = $"{label} structured wrapper"
});
```

`LayoutTarget.StructuredPanelChildWrapper` becomes a selector like:
> `[data-te-target-id='17'][data-te-target='s_wrapper']`

The expected frame comes from the scenario model; the actual `BoundingBox` comes from Chromium. The tolerance-aware `Confirm(...)` calls produce labels that identify the exact layout contract that failed.

That lets the test assert on the thing we actually care about:

- Is the structured panel child wrapper in the right place?
- Did padding affect `BlockInner` but not `BlockOuter`?
- Did a percent-anchored block resolve to the expected x/y position?
- Did a grid child land in the expected row and column?
- Did `contain` image behavior move the border target to the right layer?

This did not create a separate render path. The HTML, CSS, browser and service pipeline are the same. We only swap the final output from "give me an image" to "give me measurements I can assert on."

We still use visual tests where they make sense. This product generates images, so the final pixels matter. But for the editor, a layered test suite has been much easier to live with: unit tests for model contracts, CSS tests for generated output, local Chromium tests for layout truth and end-to-end tests for the public API.

## Conclusion

Blazor WebAssembly has real production costs. The download is heavier than a server-rendered page, trimming requires care around JavaScript interop, and startup cannot be treated as an empty gap before the application appears.

Once running, though, Blazor has been a good fit for the structured parts of this editor. We keep high-frequency browser interactions in TypeScript, route model updates through generated C#, rebuild small stylesheet components instead of block markup, and use spans and allocation-aware helpers in the paths that run constantly.

Startup work is partly technical and partly presentational. Trimming and dependency discipline reduce what ships. Preloading the template avoids another round trip. A loader that matches the real editor shell, reports progress and fades into the finished application gives the user something accurate to look at while the rest arrives.

Plain .NET tests cover the model, generated paths, variables and CSS. Local Chromium tests measure the layout contracts that only a browser can resolve. End-to-end and visual tests remain the final check rather than the first tool used for every regression.

None of this made the WASM download disappear. It did make startup predictable, release-only failures testable and ordinary editing responsive enough that the runtime is not the thing users notice.

## Template Editor Engineering Series

| Post | Description |
| --- | --- |
| [Building a Visual Template Editor in Blazor WASM](/blog/building-a-visual-template-editor-in-blazor-wasm) | A high-level tour of the decisions, architecture, and shared systems behind the editor. |
| [Building Consistent Blazor UX with Source Generators](/blog/consistent-blazor-ux-source-generators) | Turning typed C# models into consistent property controls, previews, and rendered blocks with source generators. |
| [Undo, Redo, and Live Preview - No Diffs Needed](/blog/editor-change-tracking) | Compact property addresses, generated tracking facades, transactions, undo/redo, and preview state. |
| [Why Our Blazor App Still Uses TypeScript](/blog/blazor-wasm-typescript-interop) | A practical split between Blazor-owned application state and TypeScript-owned browser behavior. |
| [Beyond Text Replacement: Building Typed Template Variables](/blog/beyond-text-replacement) | Keeping Handlebars for content while extending variables to colors, sizes, images, and other typed properties. |
| [Building a System to Generate Complex CSS](/blog/generating-complex-css) | Generating deterministic CSS for nested blocks, panels, previews, and both browser and server renderers. |
| [Simplifying Complexity: MemoryPack to the Rescue](/blog/memorypack-is-awesome) | MemoryPack storage, read-time schema repair, and typed MagicOnion communication across browser and server. |
| **Productionizing Our Blazor WebAssembly Editor** | Runtime performance, trimming, loading experience, and browser-backed testing for a production Blazor WASM editor. |
