blazor-wasm-aot
Build and publish Blazor WebAssembly apps with Mono AOT compilation on .NET 7 (net7.0), including toggling AOT on or off and explaining in plain English what the toggle changes (startup speed, publish
SKILL.mdskills/blazor-wasm-aot/SKILL.md
GitHubBlazor WASM AOT (.NET 7)
Repo-agnostic recipe for producing an AOT-compiled Blazor WebAssembly build, proven against a
net7.0 Blazor WASM client served by an ASP.NET Core host. Replace project names, paths and
output locations with the target repo's.
1. Detect the target
- Find the Blazor WASM project:
rg -l "Microsoft.NET.Sdk.BlazorWebAssembly" -g "*.csproj". - Read its
TargetFrameworkand the repo'sglobal.json— the SDK band decides the workload name and manifest feeds (step 2). - Find the project that actually emits the WASM assets: a server host that pulls in the client's static web assets (publish the host), or the client itself (static hosting).
- Check whether AOT is already wired (
RunAOTCompilationin the csproj, a Dockerfile or a publish profile) before changing anything.
2. Install prerequisites
| App TFM | Workload to install |
|---|---|
net7.0 |
wasm-tools-net7 |
net8.0 / net9.0 / net10.0 |
wasm-tools (band matches the SDK) |
- Newer SDKs do not restore workload manifests for an older TFM band, so a net7 app under an
SDK 8+ install needs the explicit, pinned install:
dotnet workload install wasm-tools-net7 --skip-manifest-update. - Install
python3as well: the workload'semccwrapper is a Python script and minimal/CI images ship without it. - Verify:
dotnet workload list,python3 --version. From the repo rootdotnet workload restorerestores whatever the project graph declares. - Build with the
dotneton PATH that matchesglobal.json(which dotnet,dotnet --info); mixing SDKs is the usual cause of restore/manifest weirdness.
3. Set the repo default
- Set
<RunAOTCompilation>true</RunAOTCompilation>in the Blazor WASM csproj when AOT builds are the repo's default. This is only the default — section 4 shows how to flip any single build. - Set it only on the Blazor WASM project — never on class libraries, tests or the server host.
- Keep
<CopyOutputSymbolsToPublishDirectory>true</CopyOutputSymbolsToPublishDirectory>(plusDebuggerSupport) so the published, optimized output stays debuggable in the browser. - Decide the globalization trade-off:
BlazorWebAssemblyLoadAllGlobalizationData=truekeeps every locale working but inflates the AOT payload; drop it and rely on invariant/small ICU if size matters more. - Preserve reflection-heavy libraries through the trimmer with
<TrimmerRootAssembly Include="..." />. Symptom of skipping it: works in dev, then after publish the app silently degrades (proven case: MSAL trimmed to a bareauthentication/login-failedredirect with no error).
4. Toggle AOT on or off
Three levers, most specific wins:
| Lever | Where it lives | Use for |
|---|---|---|
<RunAOTCompilation> |
committed csproj | the default every clone builds with |
-p:RunAOTCompilation=true|false |
the publish command | one-off builds; overrides the csproj |
-p:RunAOTCompilation=${ENABLE_AOT} |
Docker ARG / CI variable |
flipping in CI without editing source |
# fast build while iterating (seconds)
dotnet publish <proj>.csproj -c Release -o out -p:RunAOTCompilation=false
# release build with AOT (minutes)
dotnet publish <proj>.csproj -c Release -o out -p:RunAOTCompilation=true
Plain English — what the toggle actually changes:
| AOT on | AOT off | |
|---|---|---|
| What happens | C# is compiled to WebAssembly on your machine during publish | the browser downloads ordinary IL and works it out while the app loads |
| First load | noticeably faster startup — the biggest win on phones and low-end hardware | slower cold start; nobody notices during development |
| Publish time | minutes | seconds |
| Download size | larger (native dotnet.wasm grows by several MB) |
smaller |
| Debugging | needs symbols; stack traces point into native code | matches the dev build, easiest to step through |
| What can go wrong | trimming can strip reflection-heavy libraries — usually only shows up after publish | far fewer moving parts |
| Ship it? | yes — releases and perf-sensitive demos | no — dev loop, CI smoke checks, PR builds |
Rule of thumb: AOT trades publish time and download size for faster first load. Turn it off while iterating, on for anything you ship; the app behaves identically either way.
Tell which mode a build used: seconds + small dotnet.wasm = off; minutes + a grown
dotnet.wasm + native-compilation lines in the log = on. A fast build when you asked for AOT
means the flag never reached the published project (see troubleshooting).
5. Publish
dotnet publish <HostOrClient>.csproj -c Release -o artifacts/publish -p:RunAOTCompilation=true
- If the repo defines publish profiles (container/self-contained), neutralise them with
-p:PublishProfile=so output lands where you expect. - Pick the mode per section 4; everything else about the publish is identical between modes.
- For a local debugging loop, the build output already contains the client at
bin/<Config>/<tfm>/wwwroot/_framework/; publish only when producing the shippable bundle.
6. Verify the AOT build
- Confirm the boot manifest is published:
_framework/blazor.boot.jsonon net7/net8-style output (on .NET 10+ the manifest is merged into_framework/dotnet.js— probe that instead). - Confirm the mode actually ran — the tell-tales are in section 4. A publish that finished in
seconds with an unchanged
dotnet.wasmnever compiled AOT, regardless of what you asked for. - Serve the publish output and load it in a browser: framework assets must fetch 200, the app must reach first render, and cold-load time should beat the non-AOT build.
- If it ships in a container, probe the boot manifest and asset paths over HTTP from inside the
container (runtime images often lack curl — use bash
/dev/tcp).
7. Troubleshoot
| Symptom | Fix |
|---|---|
NETSDK1147: ... workloads must be installed: wasm-tools-net7 |
dotnet workload install wasm-tools-net7 --skip-manifest-update (or dotnet workload restore from the repo root) |
emcc: not found / python errors during publish |
Install python3; the workload's emcc wrapper is a Python script |
| Workload install/restore fails right after an SDK upgrade | Confirm which dotnet is the SDK pinned by global.json; manifests are per SDK band |
| Publish finishes in seconds, output identical to before | RunAOTCompilation was not applied to the published project — pass -p:RunAOTCompilation=true on the publish command and check no publish profile overrides it |
| App works in dev, breaks only after publish | Trimming: root the affected assembly with TrimmerRootAssembly |
| Publish runs out of memory / very slow in CI | Allocate more memory or split the job; AOT is inherently minutes-long — use -p:RunAOTCompilation=false for non-release builds |
| Need to debug the published AOT bundle | Keep DebuggerSupport + CopyOutputSymbolsToPublishDirectory, then attach the browser devtools via the WASM debug proxy |
Notes
- AOT is a ship-time decision, not a code change: nothing in the app behaves differently, only how it was compiled. Judge it with cold-load measurements, not build-time intuition.
- Keep both modes on one switchable flag so the fast and AOT paths cannot drift apart.