loading
Note:
One rule covers most of it A browser API can only be called once there is a browser. In practice that means: touch Butil from 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.

Blazor WebAssembly

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.

HTML
<!-- wwwroot/index.html -->
<script src="_content/Bit.Butil/bit-butil.js"></script>
<script src="_framework/blazor.webassembly.js"></script>

Blazor Server

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.

HTML
<!-- Components/App.razor -->
<script src="_content/Bit.Butil/bit-butil.js"></script>
<script src="_framework/blazor.server.js"></script>

Blazor Web App (.NET 8 and later)

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.

HTML
<!-- 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>

Blazor Hybrid (MAUI, WPF, WinForms)

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.

HTML
<!-- wwwroot/index.html -->
<script src="_content/Bit.Butil/bit-butil.js"></script>
<script src="_framework/blazor.webview.js" autostart="false"></script>

Prerendering is handled for you

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.

Razor
@inject Bit.Butil.LocalStorage localStorage

@code {
    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");
    }
}

...but a default is not an answer

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.

Razor
@inject Bit.Butil.LocalStorage localStorage
@inject Bit.Butil.UserAgent userAgent

<p>@_theme, mobile: @_isMobile</p>

@code {
    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
    }
}

Fast invoke

BitButil.UseFastInvoke / BitButil.UseNormalInvoke

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.

C#
var builder = WebAssemblyHostBuilder.CreateDefault(args);

builder.Services.AddBitButilServices();

// Once, at startup, before anything calls into Butil:
BitButil.UseFastInvoke();

await builder.Build().RunAsync();
Live sample
runtime detection output
Results will appear here when you interact with the samples.

Disposal and teardown

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.

Razor
@implements IAsyncDisposable

@code {
    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();
        }
    }
}
Warning:
The same call, three different costs An API that is one microsecond on WebAssembly is a network round trip on Blazor Server and an IPC hop on Hybrid. Code that reads 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.
Note:
Where to next?Browser support lists which APIs need a secure context, a permission or a specific engine. JavaScript trimming covers the other half of picking a hosting model - how much of Butil's JavaScript each one ends up shipping, and what to set so it ships less. Troubleshooting covers the errors these rules produce when they are broken.

Reference

Member
Signature
Description
AddBitButilServices
IServiceCollection AddBitButilServices(this IServiceCollection services)
Registers every Butil class as a scoped service, matching Blazor's one-circuit / one-app-instance-per-user model. Call it in every container that renders your components - including the prerendering host. Services are discovered by reflection rather than a hard-coded list, so a trimmed publish registers only the classes your code injects and drops the rest from the bundle.
AddBitButilServices
IServiceCollection AddBitButilServices(this IServiceCollection services, Action<BitButilOptions> configure)
Same registration, plus the library's runtime options in one place: BitButilOptions.LazyScripts (bool?), ScriptModulesPath and FastInvoke (bool?). Each maps onto the matching process-wide toggle (UseLazyScripts / UseBundledScripts, UseFastInvoke / UseNormalInvoke); null leaves a toggle as it is. Decides how scripts load and are called, not what a publish contains - see the BitButilLazyScripts and BitButilTrimScripts MSBuild properties for that.
BitButil.UseFastInvoke
void UseFastInvoke()
Enables the synchronous in-process invoke path for the APIs backed by synchronous JavaScript. WebAssembly only; a no-op elsewhere. Process-wide.
BitButil.UseNormalInvoke
void UseNormalInvoke()
Disables the fast path again; every call runs asynchronously.
BitButil.UseLazyScripts
void UseLazyScripts(string? modulesPath = null)
Loads Bit.Butil's JavaScript per module, on first use, instead of from the bit-butil.js bundle: the first call into an API import()s that API's module from _content/Bit.Butil/modules/, so only the JavaScript for the APIs actually called is downloaded and no script tag is needed. Prefer the BitButilLazyScripts MSBuild property, which also keeps the bundle out of the published output; this is the runtime override for hosts where the property cannot be applied. Process-wide, set once at startup.
BitButil.UseBundledScripts
void UseBundledScripts()
Back to expecting the whole bundle to be loaded by a script tag - the default. It selects the loading mode at runtime: it overrides the runtime effect of BitButilLazyScripts, but cannot restore a bundle that a publish with BitButilLazyScripts=true left out of the output - include and load bit-butil.js yourself before choosing bundled mode. Process-wide.
ButilSubscription
sealed class ButilSubscription : IAsyncDisposable
The token returned by every Butil event and observer subscription. Disposing detaches the listener; disposal is idempotent and safe during teardown.
ButilSubscription.Id
Guid Id
The underlying listener id, also accepted by the matching Remove(Guid) API when you want to remove subscriptions in bulk.
An unhandled error has occurred. Reload 🗙