Running .NET in the Browser

This guide answers one task: run C# in a browser as a module called from JavaScript — without adopting a UI framework — and understand what the .NET runtime costs and provides.

Prerequisites

  • [ ] .NET SDK 8 or later.
  • [ ] The wasm-tools workload installed.
  • [ ] A reason to use .NET specifically: existing libraries, existing code, or an existing team.
  • [ ] A payload budget that can absorb one to two megabytes.

The model: a runtime that runs your assemblies

.NET does not compile C# to WebAssembly in the way Rust compiles Rust. It ships a runtime — the Mono-based .NET runtime compiled to WebAssembly — which then executes your code, delivered as ordinary .NET assemblies containing intermediate language.

That means the download is the runtime plus the framework assemblies you reference plus your own, and your code’s size is the smallest part. It also means your code runs with .NET semantics: garbage collection, reflection, LINQ, async/await, and the standard library, all working as they do on a server, minus the parts that need an operating system.

Your assemblies on a runtime, not compiled to Wasm The runtime compiled to WebAssembly loads framework and application assemblies and executes their intermediate language. Ahead-of-time compilation converts that to WebAssembly at build time for speed at the cost of size. Engine.dll — your code, tens of kB framework assemblies, trimmed dotnet.native.wasm — the runtime what you get for it the standard library and NuGet garbage collection, LINQ, async existing code running unchanged ecosystem, not size Choose this when the ecosystem or the existing code is the point; choose a compiled language when the size is.

Setting up a browser-targeted project

The wasm-experimental workload gives a project template that produces a module callable from JavaScript, without Blazor.

dotnet workload install wasm-tools wasm-experimental
dotnet new wasmbrowser -o Engine
cd Engine
<!-- Engine.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <RuntimeIdentifier>browser-wasm</RuntimeIdentifier>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
    <PublishTrimmed>true</PublishTrimmed>
    <InvariantGlobalization>true</InvariantGlobalization>
  </PropertyGroup>
</Project>

InvariantGlobalization removes the internationalisation data, which is a substantial saving — several hundred kilobytes — and is only acceptable if your code does not need culture-aware formatting or comparison. Decide deliberately rather than by default.

Exporting and importing methods

The interop attributes generate the marshalling in both directions, and the supported parameter types are a specific, finite list.

using System.Runtime.InteropServices.JavaScript;

public partial class Engine
{
    [JSExport]
    internal static int Add(int a, int b) => a + b;

    [JSExport]
    internal static string Transform(string input) => input.Trim().ToUpperInvariant();

    [JSExport]
    internal static double SumArray(double[] values)
    {
        double total = 0;
        foreach (var v in values) total += v;
        return total;
    }

    [JSImport("console.log", "globalThis")]
    internal static partial void Log(string message);
}
import { dotnet } from './_framework/dotnet.js';

const { getAssemblyExports, getConfig } = await dotnet.create();
const exports = await getAssemblyExports(getConfig().mainAssemblyName);

console.log(exports.Engine.Add(2, 3));                    // 5
console.log(exports.Engine.Transform("  hello  "));       // "HELLO"
console.log(exports.Engine.SumArray(new Float64Array([1, 2, 3])));   // 6

Array parameters are copied across the boundary, not shared. For large numeric payloads that copy is the dominant cost, and the remedy is the same as everywhere else: pass the data once, keep it on the .NET side, and operate on it there rather than passing it repeatedly.

What the interop layer supports

The generated marshalling handles a specific set of types, and knowing the list saves an afternoon of compiler errors.

Primitives cross directly: int, long, double, bool, string. Arrays of primitives cross as copies, mapping onto JavaScript typed arrays. Task and Task<T> map onto promises in both directions, which makes asynchronous work comfortable. JSObject is an opaque handle to a JavaScript object that .NET can hold and pass back. Delegates cross as functions, so a callback from JavaScript into C# and back works.

What does not cross is arbitrary objects. A C# class instance cannot be handed to JavaScript as a structured value; you serialise it, or you return a handle and expose methods that operate on it. That is the same constraint every hosted language has, for the same reason: the object lives in a heap whose addresses are not stable and whose layout JavaScript cannot interpret.

[JSExport]
internal static async Task<string> FetchAndSummarise(string url)
{
    using var http = new HttpClient();          // maps onto the browser's fetch
    var body = await http.GetStringAsync(url);  // subject to CORS, like any request
    return Summarise(body);
}

HttpClient works because the runtime implements it on top of the browser’s fetch, which means CORS, credentials and mixed-content rules all apply exactly as they would to JavaScript. Code copied from a server-side project will compile and then fail at runtime for reasons that are about the browser rather than about .NET.

Ahead-of-time compilation

By default the runtime interprets or just-in-time compiles your intermediate language, which is slower than compiled code. AOT compiles it to WebAssembly at build time.

<PropertyGroup>
  <RunAOTCompilation>true</RunAOTCompilation>
  <WasmStripILAfterAOT>true</WasmStripILAfterAOT>
</PropertyGroup>

The trade is stark: execution typically improves by two to five times for compute-heavy code, and the download grows by a factor of two to four. For a module doing real numeric work behind a login, that can be worth it; for one doing occasional light work, it is not.

WasmStripILAfterAOT removes the now-redundant intermediate language, recovering part of the size increase — leave it on whenever AOT is enabled, unless something in your application uses reflection over method bodies.

Measuring what you actually shipped

Publish and look at the output, because the numbers move substantially with each setting.

dotnet publish -c Release
du -sb bin/Release/net8.0/browser-wasm/AppBundle/_framework/*.br | \
  awk '{ s += $1 } END { printf "%.2f MB compressed\n", s/1e6 }'
# 1.94 MB compressed
without trimming            4.81 MB
trimmed                     1.94 MB
trimmed + invariant globalization  1.62 MB
trimmed + AOT               5.30 MB

Those four numbers are the whole decision, and they take ten minutes to produce for your own project.

Four settings, four payloads Trimming more than halves the download. Invariant globalization removes internationalisation data. Ahead-of-time compilation increases the download substantially in exchange for faster execution. no trimming 4.81 MB trimmed 1.94 MB + invariant 1.62 MB + AOT 5.30 MB AOT is the only setting that makes the module faster, and the only one that makes it much larger. Measure both before choosing.

Expected output

dotnet publish -c Release
  Engine -> bin/Release/net8.0/browser-wasm/AppBundle/

console:
  dotnet runtime initialised in 296 ms
  Add(2, 3) = 5
  Transform("  hello  ") = "HELLO"
  SumArray(1e6 doubles) = 499999500000 in 8.4 ms (interpreted)
  SumArray(1e6 doubles) = 499999500000 in 2.1 ms (AOT)

The 296 ms initialisation is what the user waits for on every page load, cached or not — it is runtime startup rather than download, and no caching removes it.

Startup, and hiding it

Runtime initialisation is a few hundred milliseconds that no caching removes, and it happens before any of your code runs. Where you put it in the user’s experience is a design decision.

Loading lazily, when the user reaches a feature that needs it, is usually right — the same argument as every large module on this site. Starting it during idle time after first paint is a reasonable middle ground when the feature is likely to be used, and requestIdleCallback is the natural trigger.

let dotnetPromise = null;
export function startDotnet() {
  dotnetPromise ??= import('./_framework/dotnet.js')
    .then(({ dotnet }) => dotnet.create())
    .then(async (rt) => rt.getAssemblyExports(rt.getConfig().mainAssemblyName));
  return dotnetPromise;
}

// warm it while the page is idle, without blocking anything
if ('requestIdleCallback' in window) requestIdleCallback(() => startDotnet());

What you should not do is initialise it during page load and block rendering on it. A few hundred milliseconds added to first paint is a measurable regression, and the runtime is almost never needed that early.

Report the initialisation time in your telemetry alongside the download. The two behave differently — downloading improves with caching and initialisation does not — and a team tracking only the total will be puzzled when the warm number stops improving.

Where the startup time goes By default the runtime interprets the application's assemblies. Compiling them ahead of time removes the warmup at the cost of a considerably larger download. interpreted smaller download slow warmup; early interaction lags ahead-of-time much larger download — but fast from the first interaction Trimming is what makes either option viable; an untrimmed application ships far more than it uses. Measure on a phone: the interpreted warmup is barely visible on a laptop and obvious on a handset.

Gotchas

  • Trimming defeated by reflection. Doubles or triples the payload; check the published size after every dependency change.
  • Culture-dependent formatting with InvariantGlobalization. Silently different results; only enable it if you have checked.
  • Large arrays copied per call. Keep the data on the .NET side and pass indices or results.
  • Blocking on async. There is no thread to block on; .Result and .Wait() deadlock the page.
  • Assuming filesystem or socket access. Neither exists; use the browser’s APIs through interop.
  • Confusing this with Blazor. This is the runtime without a UI framework — much smaller and much less provided.

Performance note

Runtime initialisation was 296 ms and unavoidable per page load. A million-element summation took 8.4 ms interpreted and 2.1 ms with AOT, against 1.6 ms for the equivalent Rust module of 18 kB. The comparison is not close on size or startup, and it is not the point: the reason to run .NET here is that the code and the libraries already exist in .NET, and that reason is often decisive on its own.

Frequently Asked Questions

Should I use this instead of Blazor? If you want a computation module called from an existing JavaScript application, yes — it is much smaller and does not take over the page. Blazor is the right tool when you want it to own the interface.

Can I use NuGet packages? Yes, subject to trimming and to not requiring unavailable platform features. Packages doing heavy reflection will work but will inflate the payload; packages using sockets or the filesystem will not work at all.

Does it support threads? There is experimental support requiring cross-origin isolation, and it is not yet a default deployment choice. Assume single-threaded and design accordingly.

In short: the .NET runtime in a browser is an ecosystem decision with a payload attached, and it is a reasonable one whenever the alternative is rewriting working code in another language.

← Back to Other Languages in the Browser