Render modes
Butil is a bridge to the browser, so where your Blazor component happens to be running decides what it can reach. This page covers all four hosting models, what Butil does during prerendering, and the two knobs - fast invoke and disposal - that matter once you pick one.
OnAfterRenderAsync, from event handlers, or from anywhere downstream of them - never from
OnInitialized/OnParametersSet in an app that prerenders. Everything below is a
consequence of that one rule.
The .NET runtime and your component run inside the browser tab, so interop is a direct in-process call and there is no network in the path. This is the only mode where the synchronous fast-invoke path below is available. Register the services in Program.cs and add the script to wwwroot/index.html, before the Blazor script.
<!-- wwwroot/index.html -->
<script src="_content/Bit.Butil/bit-butil.js"></script>
<script src="_framework/blazor.webassembly.js"></script>Your component runs on the server and every Butil call becomes a message over the SignalR circuit and back. The API surface is identical, but each await is a network round trip - so read a value once and hold on to it rather than re-reading it per render, and prefer the observers (Intersection, Mutation, Resize) and event subscriptions over polling, since those push one message per change instead of one per poll.
<!-- Components/App.razor -->
<script src="_content/Bit.Butil/bit-butil.js"></script>
<script src="_framework/blazor.server.js"></script>A Web App mixes static SSR with interactive islands, which is the mode most likely to surprise you: the same component renders once on the server with no browser at all, then again on the client. Butil is safe in both passes (see prerendering below), but your own logic has to be. This documentation site is a Blazor Web App using InteractiveWebAssembly, so everything you are reading was prerendered first.
<!-- Components/App.razor -->
<head>
<HeadOutlet @rendermode="InteractiveWebAssembly" />
</head>
<body>
<Routes @rendermode="InteractiveWebAssembly" />
<!-- before the Blazor script, so window.BitButil exists by the time the app boots -->
<script src="_content/Bit.Butil/bit-butil.js"></script>
<script src="_framework/blazor.web.js"></script>
</body>Components run natively and render into a WebView. Butil reaches the WebView's browser engine - WebView2 on Windows, WKWebView on Apple platforms, Chromium on Android - so what is available follows that engine rather than the desktop browser your users have installed. There is a short window at startup, before the WebView attaches its IPC channel, during which no interop can be issued; Butil detects it and returns safe defaults instead of throwing.
<!-- wwwroot/index.html -->
<script src="_content/Bit.Butil/bit-butil.js"></script>
<script src="_framework/blazor.webview.js" autostart="false"></script>During static SSR and prerendering there is no JS runtime, and a raw IJSRuntime call throws. Butil recognises all three not-ready runtimes - the SSR sentinel, a Blazor Server circuit that has not initialised, and a Hybrid WebView that has not attached - and returns a safe default rather than throwing: void calls become no-ops, strings come back empty, arrays come back empty, and everything else comes back as default(T). Your prerender pass renders instead of blowing up.
Bit.Butil.LocalStorage localStorage
{
protected override async Task OnInitializedAsync()
{
// Safe during prerender - it returns "" on the server pass and the real value in the
// browser, so the component renders in both, but you cannot tell the two apart from the
// result alone.
var theme = await localStorage.GetItem("theme");
}
}Because prerender returns an empty value rather than an error, code that branches on the result silently takes the 'empty' branch on the server. Anything whose answer actually matters belongs in OnAfterRenderAsync, guarded by firstRender - that pass only ever runs in the browser. On .NET 9 and later RendererInfo.IsInteractive lets you say the same thing in the markup, which is what the header of this site uses to show its 'loading' hint while WebAssembly boots.
Bit.Butil.LocalStorage localStorage
Bit.Butil.UserAgent userAgent
<p>_theme, mobile: _isMobile</p>
{
private string _theme = "light";
private bool _isMobile;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender is false) return;
// The browser is definitely here now.
_theme = await localStorage.GetItem("theme") ?? "light";
_isMobile = await userAgent.IsMobile();
StateHasChanged(); // the first render already happened - ask for another
}
}On WebAssembly the runtime can call JavaScript synchronously, skipping the task machinery and JSON round trip that the async path pays for. Fast invoke turns that path on for the APIs whose JavaScript is genuinely synchronous - LocalStorage, SessionStorage, Cookie, Console, Location and History. Anything wrapping a Promise-returning API keeps running asynchronously no matter what this is set to, so turning it on cannot break those calls. It is a process-wide static switch meant to be set once at startup, and on Blazor Server it does nothing at all: there is no in-process runtime to use, so the calls fall back to the async path. One trimming note belongs here rather than on the trimming page: the public FastInvoke* extension methods on IJSRuntime carry [RequiresUnreferencedCode] because they serialise arbitrary payloads, so calling one of them directly from a trimmed app warns at your call site - the Butil classes themselves do not, since they only pass trim-safe primitives down that path.
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.Services.AddBitButilServices();
// Once, at startup, before anything calls into Butil:
BitButil.UseFastInvoke();
await builder.Build().RunAsync();Every Butil subscription - DOM events, observers, keyboard shortcuts, media streams, animations - hands back a token whose disposal detaches the underlying listener. Dispose it, or the JavaScript side keeps a reference to your component's delegate for as long as the page lives. Butil's own disposal paths already swallow the three exceptions that teardown legitimately produces (JSDisconnectedException when the circuit is gone, OperationCanceledException when an interop call races the shutdown, ObjectDisposedException when the runtime went first), so disposing during a Blazor Server disconnect is safe.
IAsyncDisposable
{
private ButilSubscription? subscription;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender is false) return;
subscription = await box.SubscribeEvent<ButilMouseEventArgs>(
js, ButilEvents.Click, args => { /* ... */ });
}
public async ValueTask DisposeAsync()
{
if (subscription is not null)
{
await subscription.DisposeAsync();
}
}
}Window.GetInnerWidth() inside a render loop is fine in one mode and
unusable in another - subscribe to the resize event and cache the value instead.
Reference
IServiceCollection AddBitButilServices(this IServiceCollection services)IServiceCollection AddBitButilServices(this IServiceCollection services, Action<BitButilOptions> configure)void UseFastInvoke()void UseNormalInvoke()void UseLazyScripts(string? modulesPath = null)void UseBundledScripts()sealed class ButilSubscription : IAsyncDisposableGuid Id