Why Blazor WebAssembly JavaScript Interop Silent-Fails After AOT Publish
AOT compilation can strip JavaScript interop calls in Blazor WebAssembly, leaving your C# code with no JavaScript bridge. Here’s how to protect your interop methods and verify they survive the publish pipeline.
20 Jan 2026, 17:15 UTC

Problem: Silent JavaScript Interop After AOT Publish
You have just published your Blazor WebAssembly app with AOT, fired up the browser, and clicked the button that should call a JavaScript function. The console stays silent. No JavaScript error, no C# exception--just nothing. This is not a bug in your code; it's how AOT trimming interacts with interop.
How Blazor WebAssembly JavaScript Interop Works
Blazor WebAssembly runs C# in the browser via WebAssembly. When you need a browser API--File API, Canvas, localStorage--you call into JavaScript. The framework generates a proxy method in JavaScript, and your C# code calls it as if it were a normal method. The proxy lives in a .js file you place under wwwroot, and you reference it with the [JSImport] attribute or the IJSInvokeService API. In .NET 5, interop used IJSInvokeMethod; .NET 6 introduced [JSImportFactory] for dynamic imports, and .NET 8 added stricter nullable and AOT compatibility checks. The proxy method name must match the JavaScript function name exactly, and the .js file's build action must be Content so it travels into the published output.
Why AOT Trims Interop Calls
When you build with AOT (the default for WebAssembly in recent .NET versions), the Native AOT linker analyzes your assembly and removes any code it determines is unused. If your C# interop method has no other references, the linker assumes the associated JavaScript proxy is also unused and strips it. The result: your interop call compiles, but at runtime the proxy doesn't exist, and you get silence. This is especially common when interop is only invoked through data binding, event handlers, or lazy-loaded components.
Protecting Interop with [JSImport] and MethodImpl Hints
The simplest fix is to decorate the C# import method with attributes that tell the linker to keep the bridge. The [JSImport] attribute takes the JavaScript function name as its first string and the .js file base name (without .js) as the second. Adding the MethodImplOptions.NoInlining hint prevents the linker from inlining the call and losing the interop contract. The .js file must have its build action set to Content so it's included in the published output.
Worked Example: Adding a Browser-Time Interop Call
- Create a file named browser-time.js in the
wwwrootfolder of your Blazor WebAssembly project. - Add the following content:
window.myJsTime = function() {
return new Date().toLocaleTimeString();
};
- Set the file's build action to Content in the project file or via the solution explorer. This ensures the file is packaged into the published output.
- In a Razor component, declare the import using the
[JSImport]attribute:
[JSImport('myJsTime', 'browser-time')]
public static extern string GetBrowserTime();
- Add a button and a placeholder in the markup:
Get time
@if (!string.IsNullOrEmpty(time)) { @time }
- Handle the click in the component class:
private string time;
private void ShowTime() => time = GetBrowserTime();
Verification checklist:
- .js file exists under wwwroot with build action = Content.
[JSImport]attribute uses the correct JavaScript function name as the first string and the .js base name (without .js) as the second literal.- The C# method is declared static extern.
- No other code references the method outside the interop contract, which could cause the linker to still trim it--adding
MethodImpl(MethodImplOptions.NoInlining)mitigates this risk.
Run the app with dotnet run, open the browser dev tools console, and click the button. You should see a time string appear, and the console should remain error-free. After publishing with dotnet publish -c Release -r browser-wasm, download the hosted output, open the web page, and open the console. The same call should still work because the .js file was included and the [JSImport] attribute kept the bridge visible to the linker.
Trade-off and Limitations
AOT compatibility isn't free. The [JSImport] attribute with the MethodImpl.NoInlining hint adds a small metadata overhead, and for very large interop surfaces, maintaining these annotations across many files can become tedious. Additionally, if your interop depends on dynamic JavaScript module loading--e.g., importing a script conditionally--[JSImportFactory] in .NET 6 lets you import by path at runtime, but that path must still resolve to a file packaged in the build. Server-mode Blazor (Blazor Server) routes interop calls through SignalR over WebSockets, which introduces a round-trip latency and a dependency on a stable connection; if the SignalR connection drops mid-call, the interop invocation throws an exception that you'll need to handle with try/catch or connection-state monitoring. Choose WebAssembly for local-browser APIs and low-latency interop; choose Server when you need real-time server-side processing or offline-first scenarios with fallback.
Actionable Next Steps
If your Blazor WebAssembly app's JavaScript interop went quiet after an AOT publish, start by checking that every [JSImport] attribute has the right function name and that the accompanying .js file has build action Content. Add the MethodImpl.NoInlining hint if the linker still trims the call. Then verify by running the app locally, opening the browser console, and confirming the expected output. Once you have confirmed the pattern, apply it across all your interop methods and you'll spend less time debugging silent failures and more time building features.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.