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-toolsworkload 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.
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.
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.
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;.Resultand.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.
Related
- Blazor WebAssembly for JavaScript developers — the framework built on this runtime.
- Comparing payload size across languages — .NET against the alternatives.
- Caching compiled Wasm modules in IndexedDB — making repeat visits cheaper.
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