Running .NET in the browser without Blazor

11 min read Original article ↗

~10 min read

In this post I show how you can run .NET in your browser without using Blazor, and instead rely only on the WASM base infrastructure that Blazor builds on top of. I also look at some of the improvements coming to this in .NET 10, primarily around client-side file fingerprinting.

Background:

WebAssembly (WASM) leapt onto the .NET scene back in 2017 when Steve Sanderson showed off a tech demo of what would ultimately become Blazor. Blazor is a full component-based web-framework for building web applications using HTML and C#. It can run in multiple render modes, with the Interactive WebAssembly mode running entirely in the browser using the power of WASM.

When you talk bout .NET and WASM, most people will immediately think about Blazor, but there are several other ways to combine .NET with WASM:

In addition, you can integrate Blazor components into other JavaScript frameworks like Vue or React, with improvements to this process coming in .NET 10.

In this post, I'm looking at the first approach, running .NET using WASM in the browser, but not using Blazor components.

This feature has been available from .NET 7 as best I can tell. In this post I'm using the .NET 10 preview 6 version of the workload and templates, but they haven't changed significantly.

Installing the experimental WASM templates

The templates for building a .NET application that can be run from JavaScript don't ship with the default SDK. They're experimental, so you need to explicitly install them. Which NuGet package to install depends on which version of the templates you want:

  • .NET 8: Microsoft.NET.Runtime.WebAssembly.Templates
  • .NET 9: Microsoft.NET.Runtime.WebAssembly.Templates.net9
  • .NET 10: Microsoft.NET.Runtime.WebAssembly.Templates.net10

We want the latest templates, so we'll install the .NET 10 templates (preview 6 at the time of writing)

dotnet new install Microsoft.NET.Runtime.WebAssembly.Templates.net10

This installs three templates:

Success: Microsoft.NET.Runtime.WebAssembly.Templates.net10::10.0.0-preview.6.25358.103 installed the following templates:
Template Name            Short Name   Language  Tags
-----------------------  -----------  --------  -----------------------
Wasi Console App         wasiconsole  [C#]      Wasi/WasiConsole
WebAssembly Browser App  wasmbrowser  [C#]      Web/WebAssembly/Browser
WebAssembly Console App  wasmconsole  [C#]      Web/WebAssembly/Console

Alternatively you can install the wasm-experimental workload, which includes the templates and also…a bunch of stuff 😅 I'm not really sure what that extra stuff is actually used for though, because as far as I can tell, none of it is required for this 🤷‍♂️

dotnet workload install wasm-experimental

Note that if you want to AOT compile the resulting application then you will also need to install the wasm-tools workload. This will give better performance, but it will greatly increase file size (and therefore startup time), so you'll need to decide which trade-offs to make there.

Creating a .NET WASM application

With the template installed, we can create a new application:

The template creates the following files:

Screenshot of the files generated by the template

We'll look at most of these files shortly, but first we'll run the app. You can run it with a simple dotnet run:

WasmAppHost --use-staticwebassets --runtime-config D:\repos\temp\bin\Debug\net10.0\temp.runtimeconfig.json

App url: http://localhost:5156/
App url: https://localhost:7048/
Debug at url: http://localhost:5156/_framework/debug
Debug at url: https://localhost:7048/_framework/debug

If you open the app in the browser, you'll see that the template is a simple stopwatch application. It starts as soon as you open the page, and then you can pause, reset, and start the timer:

Screenshot of the wasmbrowser application

So how does this work? For the rest of the post we'll look at the template and how it works.

Exploring the template

We'll start by looking at Program.cs, which is a top-level program that contains a helper type called StopwatchSample. The "program" itself is very simple, as shown below. First it writes to the console (which will appear in the browser's console window) and then optionally calls the static method StopwatchSample.Start() if the correct args are passed to the program. It then enters an infinite loop, calling Render() every second.

Console.WriteLine("Hello, Browser!");

if (args.Length == 1 && args[0] == "start")
    StopwatchSample.Start();

while (true)
{
    StopwatchSample.Render();
    await Task.Delay(1000);
}

The bulk of the implementation is defined in the StopwatchSample type, shown below. In general, this type is a simple wrapper around a static System.Diagnostics.Stopwatch instance. The interesting parts are the Render() method that calls SetInnerText (which is decorated with [JSImport] attribute), and the other methods that are decorated with [JSExport] attributes.

partial class StopwatchSample
{
    private static Stopwatch stopwatch = new();

    public static void Start() => stopwatch.Start();
    public static void Render() => SetInnerText("#time", stopwatch.Elapsed.ToString(@"mm\:ss"));
    
    [JSImport("dom.setInnerText", "main.js")]
    internal static partial void SetInnerText(string selector, string content);

    [JSExport]
    internal static bool Toggle()
    {
        if (stopwatch.IsRunning)
        {
            stopwatch.Stop();
            return false;
        }
        else
        {
            stopwatch.Start();
            return true;
        }
    }

    [JSExport]
    internal static void Reset()
    {
        if (stopwatch.IsRunning)
            stopwatch.Restart();
        else
            stopwatch.Reset();

        Render();
    }

    [JSExport]
    internal static bool IsRunning() => stopwatch.IsRunning;
}

As you might have guessed, [JSImport] and [JSExport] provide the means for interacting with JavaScript in the browser from your .NET Code. These attributes are used to drive two source generators, JSImportGenerator and JSExportGenerator respectively, both in Microsoft.Interop.JavaScript. As such, you can F12 to view the generated source in your IDE and see exactly what it's doing.

Ultimately it's somewhat gnarly code to read, so I'm not going to go into more detail here, but it's essentially just marshalling between the .NET (WASM) world and the JavaScript world, binding existing JavaScript functions (in the case of [JSImport]), or describing the shape of methods to expose for JavaScript to call.

A screenshot of the JSImportGenerator generated code showing marshalling code

To understand what this generated code is interacting with we'll next take a look at the HTML and JavaScript code. The HTML is very bare-bones:

<!DOCTYPE html>
<html>

<head>
  <title>temp78</title>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <!-- 👇 These are updated during dotnet run and dotnet publish -->
  <link rel="preload" id="webassembly" />
  <script type="importmap"></script>
  <script type='module' src="main#[.{fingerprint}].js"></script>
</head>

<body>
  <h1>Stopwatch</h1>
  <p>
    Time elapsed in .NET is <span id="time"><i>loading...</i></span>
  </p>
  <p>
    <button id="pause">Pause</button>
    <button id="reset">Reset</button>
  </p>
</body>

</html>

The HTML here shows the broad outline of the app that we saw earlier. It includes some link and script elements, which are required for hooking up the .NET WASM components, and it includes the basic element structure for the app, including a bunch of elements with explicit ids.

Next we look at the main.js file, which is the entrypoint for the application, as it's linked directly in the index.html file above. I've added comments to this one to explain what each step is doing:

// Import the .NET runtime support
import { dotnet } from './_framework/dotnet.js'

const { setModuleImports, getAssemblyExports, getConfig, runMain } = await dotnet
    .withApplicationArguments("start") // these are the args passed to the program
    .create(); // Set up the .NET WASM runtime 

// setModuleImports associates a set of imports (dom.setInnerText) with an
// associated module (main.js). This pair must match the values provided in the 
// [JSImport] attribute to connect everything up correctly
setModuleImports('main.js', {
    dom: {
        setInnerText: (selector, time) => document.querySelector(selector).innerText = time
    }
});

// Return information about the environment and app. e.g. Environment variables (very few)
// runtimeConfig, assembly name, referenced assemblies etc
const config = getConfig();

// get all the functions exposed in the main assembly by [JSExport], so that they
// can be invoked from JavaScript
const exports = await getAssemblyExports(config.mainAssemblyName);

// attach a click handler to the reset button and invoke the exported
// StopwatchSample.Reset() function
document.getElementById('reset').addEventListener('click', e => {
    exports.StopwatchSample.Reset();
    e.preventDefault();
});

// attach a click handler to the pause button and invoke the exported
// StopwatchSample.Toggle() function
const pauseButton = document.getElementById('pause');
pauseButton.addEventListener('click', e => {
    const isRunning = exports.StopwatchSample.Toggle();
    pauseButton.innerText = isRunning ? 'Pause' : 'Start';
    e.preventDefault();
});

// run the C# Main() method and keep the runtime process running and executing further API calls
await runMain();

That covers pretty much all there is to it. In summary:

  • [JSExport] and [JSImport] generate C# code that handles marshalling to and from JavaScript types.
  • Index.html references the bundled WASM .NET runtime and your compiled application.
  • main.js handles booting up the .NET runtime, providing the required imports for where your app needs to call into JavaScript, and running the .NET application.

A nice part of the tooling around these features is that you can just dotnet run or F5 your application and run them in the browser, but ultimately you'll want to publish your project when you run it in production.

Publishing your WASM application

You can publish your application using a simple dotnet publish -c Release and by default, the tooling will compile your application, publish and trim the framework references, and both gzip and brotli compress the output.

Another interesting point is client-side fingerprinting of these assets. .NET 9 introduced server-side fingerprint of static assets (with MapStaticAssets()) and in .NET 10 you can opt in to similar fingerprinting of the assets both for Blazor WebAssembly apps and also for Blazor-less WASM applications (as we're discussing).

To enable this behaviour you need to do several things:

  • Add <script type="importmap"></script> to your index.html.
  • Add #[.{fingerprint}] to your script references in index.html.
  • Set OverrideHtmlAssetPlaceholders=true.
  • Opt-in for your assets using <StaticWebAssetFingerprintPattern>.

These are all done by default in the template, however there's a bug with the last point, which should be fixed in an update. Read on for more details.

The first two points are covered by new additions to the templates in .NET 10, which adds the importmap and the fingerprint to main:

<link rel="preload" id="webassembly" />
<script type="importmap"></script>
<script type='module' src="main#[.{fingerprint}].js"></script>

The template also adds <link rel="preload" id="webassembly" /> which enables preloading of the webassembly files, with the intention of improving cold-start times. When you run your application, these elements are rewritten to look similar to the following, with fingerprinting of all the files:

<link href="_framework/dotnet.y5zm2li12l.js" rel="preload" as="script" fetchpriority="high" crossorigin="anonymous" integrity="sha256-eo4p7mQEfnCQ6TQ0N72uJX+t0QX8QmikrPGJjyy3QLQ=" />
  <script type="importmap">{
  "imports": {
    "./_framework/dotnet.native.js": "./_framework/dotnet.native.hwglpvp32y.js",
    "./_framework/dotnet.runtime.js": "./_framework/dotnet.runtime.0t78nptbqi.js",
    "./_framework/dotnet.js": "./_framework/dotnet.y5zm2li12l.js",
    "./main.js": "./main.ofkecrt505.js"
  },
  "scopes": {},
  "integrity": {
    "./_framework/dotnet.js": "sha256-eo4p7mQEfnCQ6TQ0N72uJX+t0QX8QmikrPGJjyy3QLQ=",
    "./_framework/dotnet.native.hwglpvp32y.js": "sha256-0S3nkr+7+aZ+9tFRQOEYkmozryFXfrzgl+nv+qz71QM=",
    "./_framework/dotnet.native.js": "sha256-0S3nkr+7+aZ+9tFRQOEYkmozryFXfrzgl+nv+qz71QM=",
    "./_framework/dotnet.runtime.0t78nptbqi.js": "sha256-fBs/I1SdlqDOQOqxGF+LdElB3o5/FirA8fyIHRUy9cE=",
    "./_framework/dotnet.runtime.js": "sha256-fBs/I1SdlqDOQOqxGF+LdElB3o5/FirA8fyIHRUy9cE=",
    "./_framework/dotnet.y5zm2li12l.js": "sha256-eo4p7mQEfnCQ6TQ0N72uJX+t0QX8QmikrPGJjyy3QLQ=",
    "./main.js": "sha256-9EZteoeGyecFbFTmMweXxx9ItCAClDZ8n+6lXEEulSU=",
    "./main.ofkecrt505.js": "sha256-9EZteoeGyecFbFTmMweXxx9ItCAClDZ8n+6lXEEulSU="
  }
}</script>
  <script type='module' src="main.ofkecrt505.js"></script>
</head>

In the .csproj file, the template also adds the required OverrideHtmlAssetPlaceholders and StaticWebAssetFingerprintPattern entries, which enables the above behaviour:

<Project Sdk="Microsoft.NET.Sdk.WebAssembly">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
    <!-- 👇 Required for fingerprinting -->
    <OverrideHtmlAssetPlaceholders>true</OverrideHtmlAssetPlaceholders>
  </PropertyGroup>

  <ItemGroup>
    <!-- 👇 Required for fingerprinting -->
    <StaticWebAssetFingerprintPattern Include="JS" Pattern="*.js" />
  </ItemGroup>
</Project>

However. If you publish your application and inspect the index.html file you'll see that the main.js fingerprint is conspicuously missing:

<!--           No fingerprint 👇 -->
<script type='module' src="main.js"></script>

So what's going on here🤔 It turns out this was a bug in the template, but you can work around it by making a change to your template:

- <StaticWebAssetFingerprintPattern Include="JS" Pattern="*.js" />
+ <StaticWebAssetFingerprintPattern Include="JS" Pattern="*.js" Expression="#[.{fingerprint}]!" />

Adding the Expression="#[.{fingerprint}]!" attribute (which is referenced in the documentation) resolves the issue on publish, and ensures the fingerprint is added to the script files.

Reducing the size of the published application

Out of interest I checked the published size of this sample app (in release mode) and it looks roughly like the following:

  • 6.8MB uncompressed
  • 2.5MB compressed (gzip)
  • 2.0MB compressed (brotli)

That includes all the files, including the .NET runtime, so that's not bad. The runtime is obviously heavily trimmed to reach these sizes, but can we go smaller? One obvious standout were icu assemblies, so I wondered if enabling globalization invariant mode could reduce things further. I added the following to the project file:

<InvariantGlobalization>true</InvariantGlobalization>

and ran dotnet publish -c Release again. And sure enough, there's some nice gains to be had:

  • 4.3MB uncompressed—2.5MB reduction
  • 1.7MB compressed (gzip)—0.8MB reduction
  • 1.4MB compressed (brotli)—0.6MB reduction

That gave a 30-37% reduction in total app size, which is a pretty nice reduction! Obviously it depends on your application as to whether globalization invariant mode is feasible or not, but it's a handy tool to have if so.

And that's all there is to it. This approach to running .NET code in JavaScript is much more low level than using Blazor or interacting with other web frameworks, so it's much less likely that you'll see big value from plugging in at this layer. However, if you don't need Blazor, then this might be just what you need!

Summary

In this post I described the various ways .NET code can run using WebAssembly (WASM), focusing on running .NET code in the browser without using the Blazor web component framework. I walked through the basic template for running .NET in the browser using WASM, examining both the .NET and JavaScript code to understand how they work together. Finally, I looked at some changes to the client-side fingerprinting in .NET 10 that enable cache-busting fingerprinting for your published assets.

Andrew Lock | .Net Escapades

Want an email when
there's new posts?