Blazor WebAssembly for JavaScript Developers

This guide answers one task: understand what Blazor WebAssembly actually does, what it costs to load, and how it interoperates with JavaScript — enough to evaluate it honestly if a .NET team proposes it, or to integrate with it if you inherit one.

Prerequisites

  • [ ] The .NET SDK 8 or later, if you want to build the examples.
  • [ ] Familiarity with the browser’s module loading, which this page assumes.
  • [ ] No .NET experience required; the point is the model, not the language.
  • [ ] A payload budget, because that is where the discussion always ends up.

What actually ships to the browser

This is the part that surprises people coming from Rust. Blazor does not compile your C# to WebAssembly. It ships a .NET runtime compiled to WebAssembly, plus your application as ordinary .NET assemblies, and the runtime executes them.

So the browser downloads three things: the runtime as a .wasm file, the framework’s assemblies, and your application’s assemblies. The runtime interprets or just-in-time compiles the intermediate language in the assemblies, in much the same way it would on a server.

Ahead-of-time compilation changes this. With AOT enabled, your code and the framework are compiled to WebAssembly at build time, producing faster execution and a considerably larger download — the classic trade, taken to an unusual extreme here.

Three layers, all downloaded A .NET runtime compiled to WebAssembly executes framework and application assemblies that are shipped as intermediate language. Ahead-of-time compilation replaces interpretation with compiled code at the cost of a much larger download. your application assemblies — a few hundred kilobytes framework assemblies — trimmed, still substantial .NET runtime compiled to WebAssembly the floor — present before any of your code runs A Rust or C module compiles your code directly; Blazor ships a runtime that then executes your code, which is why its payload floor is an order of magnitude higher.

The payload, stated plainly

A minimal Blazor WebAssembly application, published in release with trimming and Brotli compression, lands somewhere between 1.2 and 2.5 MB. With AOT compilation it can reach 4–8 MB, in exchange for substantially faster execution.

dotnet publish -c Release
# check what actually ships
du -sh bin/Release/net8.0/publish/wwwroot/_framework/
# 2.1M    _framework/
ls bin/Release/net8.0/publish/wwwroot/_framework/*.br | wc -l
# 84

That is not a disqualification, and it is not a detail either. For a public page measured on first load it rules Blazor out. For an internal application behind a login, used daily by people on corporate networks, cached after the first visit — which describes an enormous amount of business software — it is a cost paid once and rarely thought about again.

Trimming matters more here than in most stacks: the linker removes unreferenced code from the framework assemblies, and an application that defeats trimming through reflection can be several times larger than one that does not.

JavaScript interop, both directions

Blazor code cannot touch the DOM directly any more than a Rust module can, and it exposes an explicit interop layer for reaching JavaScript.

// calling JavaScript from C#
@inject IJSRuntime JS

private async Task ShowChart(double[] data)
{
    await JS.InvokeVoidAsync("charts.render", "#chart", data);
}

private async Task<string> ReadClipboard()
{
    return await JS.InvokeAsync<string>("navigator.clipboard.readText");
}
// calling C# from JavaScript
window.charts = {
  async onPointClick(index) {
    await DotNet.invokeMethodAsync('MyApp', 'HandlePointClick', index);
  },
};
[JSInvokable]
public static Task HandlePointClick(int index) { /* … */ return Task.CompletedTask; }

Every call across that boundary is marshalled, and by default serialised as JSON. That makes it comfortable for occasional calls and expensive in a loop — the same rule as every other WebAssembly interop story, with the same answer: batch, and keep the chatty work on one side.

For bulk numeric data there is an unmarshalled path and, more usefully, IJSStreamReference for transferring large payloads without a JSON round trip. Reach for those when a profile shows the boundary rather than by default.

How it feels from the JavaScript side

If you are integrating with a Blazor application rather than writing one, three things are worth knowing.

The DOM it produces is ordinary DOM, so your CSS, your analytics and your accessibility tooling all work. What does not work is mutating that DOM from outside: Blazor maintains its own render tree and will overwrite changes it did not make, exactly as React would.

Startup is visible. The runtime downloads, initialises, and only then does the application render, so a Blazor page has a longer gap between first paint and interactivity than a JavaScript one. Applications usually fill it with a loading indicator defined in the host HTML, which is the one piece of the page that exists before .NET does.

And errors surface differently. An unhandled .NET exception logs a stack trace through the interop layer into the console, which is readable but unfamiliar, and the line numbers refer to C# rather than to anything you can inspect in the sources panel without extra tooling.

What the user sees while .NET starts The host page paints immediately with a loading indicator. The runtime and assemblies download and initialise, and the application renders only afterwards, which makes the gap longer than for a JavaScript application. host HTML paints runtime + assemblies download — loading indicator runtime init app renders On a second visit the download disappears and the sequence is dominated by runtime initialisation, which is fast — which is why this model suits applications people return to. Design the loading indicator deliberately; it is the first impression on every cold visit and it lives in plain HTML, outside the framework. Blazor Server is the alternative that removes the download entirely at the cost of requiring a live connection for every interaction.

Reducing what ships

If the payload is the only thing standing between you and a workable choice, several levers exist and they compound.

Trimming is the largest and is on by default in release publishes. It removes unreferenced framework code, and the amount it can remove depends entirely on how much reflection your application and its dependencies use. Annotating assemblies as trim-compatible and replacing reflection-heavy libraries is the single most productive optimisation available.

Lazy loading of assemblies moves rarely used parts of the application out of the initial download and fetches them when a route is reached. It works well for a large application with distinct areas, and it requires the routes to be genuinely separable.

<ItemGroup>
  <BlazorWebAssemblyLazyLoad Include="Reporting.dll" />
  <BlazorWebAssemblyLazyLoad Include="Admin.dll" />
</ItemGroup>

Compression is not optional. Publishing produces Brotli-compressed copies of every asset, and the host must be configured to serve them — a server that ignores the .br files triples the transfer for no reason, which is a depressingly common misconfiguration.

Finally, consider whether the whole application needs this model. A hybrid where most pages are server-rendered and one genuinely interactive area uses Blazor keeps the payload off pages that do not need it, in the same way a JavaScript application would lazily load a heavy editor.

Expected output

A published application shows its size breakdown, which is the number the decision rests on:

dotnet publish -c Release
  MyApp -> bin/Release/net8.0/publish/

wwwroot/_framework/
  dotnet.native.wasm.br        1.31 MB
  System.Private.CoreLib.dll.br  412 kB
  MyApp.dll.br                    38 kB
  … 81 more files
  total                         2.14 MB
console on first load:
  blazor: loaded 84 resources (2.14 MB) in 1840 ms
  blazor: runtime initialised in 310 ms
  first render at 2210 ms

Two seconds to interactive on a fast connection, and near zero on a warm cache. Whether that is acceptable is a product question, and it is the right question to ask first.

What the runtime download costs The browser model downloads a runtime and the application's assemblies before anything runs. The server model keeps both on the server and sends interface updates over a socket. Blazor WebAssembly runtime + assemblies, then time to interactive on a phone Blazor Server small payload interactive quickly, but every interaction is a round trip Trimming and ahead-of-time compilation move the first number substantially; they do not remove it. The browser model wins where offline or latency matters, and loses where the first paint matters most.

Gotchas

  • Evaluating Blazor on a public marketing page. Wrong fit; the payload floor decides it.
  • Trimming defeated by reflection. Produces a much larger download than necessary; check the published size after every dependency addition.
  • Mutating Blazor’s DOM from JavaScript. It will be overwritten. Use interop to ask the application to change it.
  • Chatty interop. JSON marshalling per call adds up quickly in a loop.
  • AOT enabled without measuring. It multiplies the download; verify the execution gain is one you actually need.
  • Confusing Blazor WebAssembly with Blazor Server. Completely different deployment models with different tradeoffs; make sure everyone is discussing the same one.

Performance note

For a moderate line-of-business application: 2.14 MB compressed, 2.2 s to first render cold, 280 ms warm. Execution of ordinary interface logic was comparable to JavaScript under the interpreter and noticeably faster with AOT, at the cost of a 5.8 MB download. The pattern that fits is clear from those numbers — applications people open every day, not pages people visit once.

Frequently Asked Questions

Is Blazor a reasonable choice in 2026? For a .NET team building an internal or authenticated application, yes: one language across the stack, mature tooling, and a payload that is paid once. For anything where first load is the metric, it is the wrong tool and no amount of trimming changes that.

Can I use Blazor components inside a JavaScript application? Yes, through custom elements, which lets a Blazor component be embedded into a page owned by another framework. The runtime still loads, so the payload arrives with the first such component.

What is the debugging experience like? Better than most WebAssembly stacks: the tooling supports breakpoints in C# from the browser’s debugger, with the usual caveats about release builds. It is one of the genuine advantages of the runtime model — the runtime knows about your code in a way a stripped compiled module does not.

How does it compare with a Rust framework? Different floors and different ecosystems. A Rust framework compiles your code with no runtime beneath it, so a comparable application is five to ten times smaller; the .NET ecosystem is vastly larger and the tooling more mature. Choose on team and payload, not on elegance.

The summary worth carrying away: Blazor is a runtime-in-the-browser model rather than a compile-to-Wasm model, and every one of its tradeoffs follows from that single fact.

← Back to Full-Stack Frameworks with Wasm