loading
Warning:
A worker runs a script you supply There is no second .NET runtime behind this. The URL you pass is fetched and run as JavaScript, and the conversation with it is messages rather than method calls - which is why the payload type is ButilMessage and not something typed. This page talks to demo-worker.js and demo-shared-worker.js, both served by the demo site. The script must be same-origin, or a blob: URL the page made.

Support check

IsSupported / IsSharedSupported

Dedicated workers are supported everywhere. SharedWorker is not - shipping engines have dropped and re-added it, so check before relying on it. During prerender/SSR both checks return false rather than throwing, so defer them to OnAfterRenderAsync.

C#
@inject Bit.Butil.Worker worker

var supported = await worker.IsSupported();
var shared = await worker.IsSharedSupported();
Live sample
support check output
Results will appear here when you interact with the samples.

Start one and talk to it

Create / PostMessage / ButilMessage

A script that 404s or throws while loading does not fail Create - the constructor returns before the script has been fetched, so those arrive through onError instead. Messages are JSON in both directions: what survives is what System.Text.Json can write and JSON.parse can read, not a structured clone of a .NET object.

@implements IAsyncDisposable
@inject Bit.Butil.Worker worker

<button @onclick="Start">Create</button>
<button @onclick="Echo">Echo</button>
<p>@_reply</p>

@code {
    private WorkerHandle? _worker;
    private string? _reply;

    private record Reply(string Op, string Payload, string Name);

    private async Task Start()
    {
        _worker = await worker.Create("/workers/demo-worker.js",
            onMessage: m => InvokeAsync(() =>
            {
                // m.Json is always valid JSON - even a plain string arrives quoted
                _reply = m.Deserialize<Reply>()?.Payload;
                StateHasChanged();
            }),
            options: new WorkerOptions { Name = "butil-demo" },
            onError: e => InvokeAsync(() =>
            {
                _reply = $"error: {e.Message}";
                StateHasChanged();
            }));
    }

    private async Task Echo() => await _worker!.PostMessage(new { op = "echo", payload = "hello" });

    public async ValueTask DisposeAsync()
    {
        if (_worker is not null) await _worker.DisposeAsync();
    }
}
Live sample
Worker Not started.
worker output
Results will appear here when you interact with the samples.

The reason workers exist

PostMessage

The worker computes fib(n) with the naive recursion, which blocks its thread for seconds. Press the counter button while it runs: the page keeps counting. Run the same loop on the main thread and nothing would repaint until it finished.

@code {
    private record FibReply(string Op, int N, long Value, int Milliseconds);

    private async Task RunFib(int n)
    {
        // The reply carries the value and how long the worker's thread was blocked, and arrives on
        // the onMessage callback given to Create - nothing here waits for it.
        await _worker!.PostMessage(new { op = "fib", n });
    }

    // ... in the onMessage callback passed to worker.Create:
    //     var reply = m.Deserialize<FibReply>();
}
Live sample
offload output
Results will appear here when you interact with the samples.

Binary, without a copy

PostBytes

Transferring moves the ArrayBuffer to the worker instead of copying it, which is why bytes rather than JSON is the right shape for anything large: a copy of a hundred megabytes costs a hundred megabytes and a transfer costs nothing. The demo worker increments each byte and transfers the buffer straight back.

@code {
    private async Task SendBytes() => await _worker!.PostBytes([1, 2, 3, 4], transfer: true);

    // ... in the onMessage callback passed to worker.Create:
    //     if (m.IsBinary) { /* m.Data holds [2, 3, 4, 5] */ }
}
Live sample
binary output
Results will appear here when you interact with the samples.

Errors do not stop a worker

WorkerError

An uncaught throw inside the worker reaches onError and the worker carries on answering messages. If that is not what you want, terminate it yourself. A cross-origin script is reported as the bare string 'Script error.' with no location - the browser withholds the detail rather than leak it.

@code {
    private async Task Start() =>
        _worker = await worker.Create("/workers/demo-worker.js",
            onMessage: m => InvokeAsync(StateHasChanged),
            onError: e => InvokeAsync(() =>
            {
                // e.Message, e.FileName, e.LineNumber, e.ColumnNumber
                _error = $"{e.Message} ({e.FileName}:{e.LineNumber})";
                StateHasChanged();
            }));

    private async Task MakeItThrow()
    {
        await _worker!.PostMessage(new { op = "throw" });
        // ... and then this still works:
        await _worker.PostMessage(new { op = "echo", payload = "still here" });
    }
}
Live sample
error output
Results will appear here when you interact with the samples.

Handing the worker a private line

PostWithPorts / MessagePortHandle

A channel has two ends. Give one to the worker and keep the other, and the two of them talk directly - the code that brokered the introduction is not part of the conversation. Ports are transferred rather than copied: once sent, the handle on this side stops working, which is what makes the line private.

@inject Bit.Butil.MessageChannel messageChannel

@code {
    private MessageChannelHandle? _channel;

    private async Task GiveWorkerAPort()
    {
        _channel = await messageChannel.Create();
        await _channel!.Port2.OnMessage(m => InvokeAsync(StateHasChanged));
        await _channel.Port2.Start();

        // Port1 goes to the worker - and stops working here
        await _worker!.PostWithPorts(new { op = "takePort" }, [_channel.Port1]);
    }

    private async Task SendOverPort() => await _channel!.Port2.PostMessage(new { hello = "worker" });
}
Live sample
port output
Results will appear here when you interact with the samples.

One worker, every tab

CreateShared / SharedWorkerHandle

A shared worker is one instance for every page of the origin naming the same script and name - so the name is part of its identity here rather than being cosmetic. Everything goes through its Port, which delivers nothing until Start is called. No page can terminate it for the others: disposing drops this page's connection, and the worker ends when the last one goes. Open this page in a second tab to see the connection count.

@code {
    private SharedWorkerHandle? _shared;

    private async Task Connect()
    {
        _shared = await worker.CreateShared("/workers/demo-shared-worker.js",
            new WorkerOptions { Name = "butil-demo-shared" });

        await _shared!.Port.OnMessage(m => InvokeAsync(StateHasChanged));
        await _shared.Port.Start();
    }

    private async Task Broadcast() =>
        await _shared!.Port.PostMessage(new { op = "broadcast", payload = "hello, other tabs" });

    private async Task Disconnect()
    {
        // The worker is never told a page went away, so this page says so before it goes.
        await _shared!.Port.PostMessage(new { op = "disconnect" });
        await _shared.DisposeAsync();
        _shared = null;
    }
}
Live sample
Connection Not connected.
shared worker output
Results will appear here when you interact with the samples.
Note:
Terminate is not a shutdownTerminate stops the worker mid-statement. Nothing inside it gets a chance to run, so if it holds something that needs closing, tell it to close it and wait for the reply first. As a safety net the Worker service terminates any dedicated worker left behind when its scope is torn down.

API reference

Member
Signature
Description
IsSupported
ValueTask<bool> IsSupported()
True when the runtime exposes Worker. Returns default (false) during prerender/SSR instead of throwing.
IsSharedSupported
ValueTask<bool> IsSharedSupported()
True when the runtime exposes SharedWorker, which is not universal.
Create
ValueTask<WorkerHandle?> Create(string scriptUrl, Action<ButilMessage> onMessage, WorkerOptions? options = null, Action<WorkerError>? onError = null)
Starts a dedicated worker. Null when Worker is missing or the URL was refused. A script that 404s or throws surfaces through onError instead.
CreateShared
ValueTask<SharedWorkerHandle?> CreateShared(string scriptUrl, WorkerOptions? options = null)
Connects to a shared worker, starting it if this is the first page to ask. Script URL and name together decide which worker that is.
DisposeAsync
ValueTask DisposeAsync()
On scope/circuit teardown, terminates any dedicated worker whose handle was never disposed.
WorkerHandle.PostMessage
ValueTask<bool> PostMessage<T>(T value, JsonSerializerOptions? options = null)
Posts a message as JSON. False when the worker has been terminated.
WorkerHandle.PostBytes
ValueTask<bool> PostBytes(byte[] data, bool transfer = true)
Posts raw bytes. Transferring moves the buffer to the worker instead of copying it.
WorkerHandle.PostWithPorts
ValueTask<bool> PostWithPorts<T>(T value, MessagePortHandle[] ports, JsonSerializerOptions? options = null)
Posts a message that hands ports to the worker. The ports are transferred, so the handles passed here stop working.
WorkerHandle.Terminate
ValueTask Terminate()
Stops the worker immediately, mid-statement. Idempotent.
SharedWorkerHandle.Port
MessagePortHandle Port
The port this page talks to the worker over. Delivers nothing until Start is called.
SharedWorkerHandle.DisposeAsync
ValueTask DisposeAsync()
Drops this page's connection. The worker lives on for whoever else is connected.
WorkerOptions
class { string? Name; bool Module; string? Credentials; }
Name (part of a shared worker's identity), Module to run the script as an ES module, and Credentials for a module script's fetch.
WorkerError
record (string Message, string FileName, int LineNumber, int ColumnNumber)
An uncaught failure inside the worker. A cross-origin script is reported as 'Script error.' with no location.
ButilMessage
record (bool IsBinary, string? Json, byte[]? Data) { T? Deserialize<T>() }
One message. Binary payloads stay binary; everything else is JSON - always valid JSON, so a plain string arrives quoted.
An unhandled error has occurred. Reload 🗙