You inject IJSRuntime, call it from OnInitializedAsync, and ship it. On your machine it works every single time. In production it throws InvalidOperationException.
Nothing about your code changed. What changed is where it ran.
In dev, your first navigation is usually a hard reload, so by the time you look, the circuit is up and the DOM is there. In production, users land mid-flow, the prerender pass renders your component with no browser, no DOM, and no live SignalR circuit, and the call blows up.
This is the single most common JavaScript interop bug in Blazor, and it is a symptom of something bigger: ASP.NET Core 10 Blazor is SSR-first, and most of us are still writing interop like it is 2020.
SSR-first changed the rules
Three facts that invalidate a lot of habits:
IJSRuntime is not available during static SSR. Not “unreliable.” Not available. There is no browser on the other end of the call.
It is not available during the prerender pass of any interactive mode either. InteractiveServer, InteractiveWebAssembly, InteractiveAuto: they all prerender first. Your component runs twice, and the first run has nowhere to send a JS call.
Enhanced navigation breaks the script tag you have always used. That <script> in App.razor executes on first load. Navigate to another page and it does not run again. Module-level state from the previous page leaks into the next one. Meanwhile every page pays the parse-and-evaluate cost, including the pages that never call the function.
Four habits worth dropping
Globals on window. Names collide across teams, the bundler cannot tree-shake anything attached to window, and every function is reachable from the dev tools console. Collocated .razor.js modules solve all of it.
Polling the DOM with setInterval. It races hydration, leaks timers, pegs the CPU, and silently breaks when Blazor re-renders. ElementReference with @ref exists precisely so you never have to guess whether the node is there yet.
Letting JavaScript own state. When state lives only in JS, StateHasChanged never fires, parents read stale data on submit, and the next render can wipe the JS-managed DOM out from under you. .NET stays the source of truth. DotNetObjectReference plus a [JSInvokable] instance method is how JavaScript tells .NET something happened.
Skipping disposal. Every IJSObjectReference and every DotNetObjectReference you hold has to be disposed, or you leak the whole component graph. And on InteractiveServer, disposal itself can throw JSDisconnectedException when a user closes the tab mid-cleanup. If you are not wrapping those calls, your production logs are full of stack traces generated by people simply navigating away.
.NET 10 closed some real gaps
InvokeConstructorAsync instantiates a JavaScript class with new and hands back an IJSObjectReference. GetValueAsync<T> and SetValueAsync read and write JS object properties directly instead of round-tripping through helper functions. And CancellationToken support finally gives you timeouts on calls that might never resolve, because a Promise that never settles blocks the awaiting Task forever.
Small APIs. They remove a surprising amount of glue code.
The rule I keep coming back to
Before any interop call, ask one question: can Blazor do this natively? If yes, stop.
Forms, navigation, enhanced nav, and bind get you further than most developers expect. Interop earns its place in exactly three situations: browser-only APIs you cannot reach from .NET (clipboard, IndexedDB, geolocation), third-party widgets you cannot afford to rebuild (Quill, Chart.js, mapping SDKs), and legacy code you do not own. Almost everything else is better in C#.
JavaScript in modern Blazor should be a precision tool, not a default.
What I built to teach this
My new Pluralsight course, JavaScript Interop in ASP.NET Core 10 Blazor, is live.
It is built around ReadList, a book-tracking app with five surfaces, each deliberately mapped to a different render mode and a different interop pattern: a public reading list on static SSR with zero JavaScript, a book detail page with selective interactivity, a Quill review editor on InteractiveServer, a stats chart on InteractiveAuto, and a preferences page on WebAssembly using [JSImport] and [JSExport].
Across four modules and about two hours, you will work through:
- When interop calls succeed and when they throw, per render mode
- Loading JavaScript without breaking SSR or enhanced navigation
- ElementReference, module isolation, and disposal that survives disconnects
- DotNetObjectReference and [JSInvokable] for callbacks into .NET
- Serialization defaults that bite (fields are never serialized, names get camelCased, types need a public default constructor, the trimmer removes interop-only types on publish)
- Binary data and the 32 KB SignalR inbound cap
- Integrating Chart.js end to end without fighting the renderer
You finish with a checklist you can apply to every interop decision in your own projects.
Watch it here: https://www.pluralsight.com/courses/javascript-interop-asp-dot-net-core-10-blazor
Scan to watch here:

If you have ever shipped an interop call that worked locally and failed for your users, this course is the two hours that stops it happening again.
The QR code in this post was made with ReSlug. If you want branded codes with your own logo in the middle, sitting on a short link you can repoint later without reprinting anything, it does both in one place: https://reslug.com

