You have a Blazor WebAssembly app that needs to generate a PDF, parse a large CSV, or crunch some image data. You click the button and the entire UI locks up. The browser tab goes unresponsive. Users start rage-clicking. The problem is well understood: WebAssembly runs on the browser's main thread, and any CPU-intensive work starves the rendering pipeline.

Web Workers have been the browser's answer to this for over a decade, but using them from Blazor meant hand-rolling JavaScript interop, manually booting a second .NET runtime in the worker script, and wiring up postMessage plumbing yourself. Community libraries like BlazorWorker and SpawnDev.BlazorJS.WebWorkers filled the gap, but each came with its own abstractions and trade-offs.

.NET 11 Preview 2 changes this with dotnet new webworker -- a first-party project template that scaffolds the JavaScript worker scripts and a C# WebWorkerClient class, removing the need to write the interop layer by hand. It is not limited to Blazor either; the template works with any .NET WebAssembly host, including standalone wasmbrowser apps and custom JavaScript frontends.

What the template gives you

Run the template and you get a Razor class library with a clean structure:

dotnet new webworker -n MyWorker
MyWorker/
├── MyWorker.csproj
├── WebWorkerClient.cs
└── wwwroot/
    ├── dotnet-web-worker-client.js
    └── dotnet-web-worker.js

Three files do all the heavy lifting:

You reference this class library from your Blazor app, define your worker methods in the main project, and call them through WebWorkerClient. No raw JavaScript required.

Setting up the projects

Start with a standard Blazor WebAssembly app and add the worker library as a reference:

create-projects.sh
dotnet new blazorwasm -n SampleApp
dotnet new webworker -n WebWorker
cd SampleApp
dotnet add reference ../WebWorker/WebWorker.csproj

The worker relies on [JSExport], which requires unsafe code support. Enable it in your app's project file:

SampleApp/SampleApp.csproj
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
  <PropertyGroup>
    <TargetFramework>net11.0</TargetFramework>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
  </PropertyGroup>
</Project>

// NOTE

AllowUnsafeBlocks is required for the [JSExport] and [JSImport] interop attributes. This does not mean your application code needs to use unsafe -- it is a requirement of the underlying interop machinery.

Defining worker methods

Worker methods live in the main application project because the assembly name must match the one loaded by the worker runtime. They must be static methods in a static partial class, decorated with [JSExport]:

SampleApp/Workers/PricingWorker.cs
using System.Runtime.InteropServices.JavaScript;
using System.Runtime.Versioning;
using System.Text.Json;

[SupportedOSPlatform("browser")]
public static partial class PricingWorker
{
    [JSExport]
    public static string CalculatePortfolio(string holdingsJson)
    {
        var holdings = JsonSerializer.Deserialize<List<Holding>>(holdingsJson)!;

        var results = holdings.Select(h => new PortfolioResult(
            h.Symbol,
            h.Quantity * h.CurrentPrice,
            ComputeVolatility(h.PriceHistory)
        )).ToList();

        return JsonSerializer.Serialize(results);
    }

    private static double ComputeVolatility(double[] prices)
    {
        if (prices.Length < 2) return 0;

        var returns = new double[prices.Length - 1];
        for (int i = 1; i < prices.Length; i++)
            returns[i - 1] = (prices[i] - prices[i - 1]) / prices[i - 1];

        var mean = returns.Average();
        var variance = returns.Sum(r => (r - mean) * (r - mean)) / returns.Length;
        return Math.Sqrt(variance);
    }
}

public record Holding(string Symbol, int Quantity, double CurrentPrice, double[] PriceHistory);
public record PortfolioResult(string Symbol, double MarketValue, double Volatility);

There is a critical constraint here: [JSExport] methods can only return primitives or strings. For complex types, you must serialise to JSON and let the client deserialise. This is a limitation of the JavaScript interop layer, not of Web Workers themselves.

Calling the worker from a component

Inject IJSRuntime and use WebWorkerClient.CreateAsync to spin up the worker. The client handles all the postMessage choreography:

SampleApp/Pages/Portfolio.razor.cs
using System.Runtime.Versioning;
using System.Text.Json;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using WebWorker;

namespace SampleApp.Pages;

[SupportedOSPlatform("browser")]
public partial class Portfolio : ComponentBase, IAsyncDisposable
{
    [Inject] private IJSRuntime JSRuntime { get; set; } = default!;

    private WebWorkerClient? worker;
    private List<PortfolioResult>? results;
    private bool isCalculating;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            worker = await WebWorkerClient.CreateAsync(JSRuntime);
            StateHasChanged();
        }
    }

    private async Task RunCalculation()
    {
        if (worker is null) return;

        isCalculating = true;
        StateHasChanged();

        results = await worker.InvokeAsync<List<PortfolioResult>>(
            "SampleApp.PricingWorker.CalculatePortfolio",
            [JsonSerializer.Serialize(holdings)]);

        isCalculating = false;
        StateHasChanged();
    }

    public async ValueTask DisposeAsync()
    {
        if (worker is not null)
        {
            await worker.DisposeAsync();
        }
    }
}

Notice the method name passed to InvokeAsync uses the fully qualified format: AssemblyName.ClassName.MethodName. Get this wrong and you will get a silent failure -- no exception, just a promise that never resolves.

The WebWorkerClient API

The API surface is intentionally small:

Example.cs
public sealed class WebWorkerClient : IAsyncDisposable
{
    public static async Task<WebWorkerClient> CreateAsync(
        IJSRuntime jsRuntime);

    public async Task<TResult> InvokeAsync<TResult>(
        string method, object[] args,
        CancellationToken cancellationToken = default);

    public async ValueTask DisposeAsync();
}

// TIP

Create the worker once during component initialisation and reuse it for multiple calls. Spinning up a new worker per request defeats the purpose -- you are paying the runtime boot cost every time.

When to use workers vs BackgroundService

Not every background task needs a Web Worker. The key distinction is CPU-bound versus I/O-bound work:

Use a Web Worker when:

Stick with BackgroundService / async patterns when:

A BackgroundService in Blazor WebAssembly still runs on the main thread -- it just yields control between await points. A Web Worker runs on a genuinely separate thread. This distinction matters when you are deciding whether the overhead of a second .NET runtime is justified.

Beyond Blazor

The webworker template is not Blazor-specific. In non-Blazor scenarios -- standalone wasmbrowser apps or custom JavaScript frontends like React -- you can import the generated dotnet-web-worker-client.js directly from your entry point and call [JSExport] methods without the C# WebWorkerClient class:

main.js
import { DotNetWebWorkerClient } from './dotnet-web-worker-client.js';

const worker = await DotNetWebWorkerClient.create();
const result = await worker.invoke('MyApp.PricingWorker.CalculatePortfolio', [holdingsJson]);
console.log(JSON.parse(result));

This makes the template useful well beyond the Blazor ecosystem. If you are building a .NET WebAssembly module consumed by a JavaScript frontend, the worker template handles the runtime bootstrapping regardless of your UI framework.

Common pitfalls

Forgetting AllowUnsafeBlocks The build will fail with a cryptic error about JSExportAttribute if you omit <AllowUnsafeBlocks>true</AllowUnsafeBlocks> from your project file. This trips up almost everyone on first setup.

Wrong method name format InvokeAsync requires the fully qualified name: AssemblyName.ClassName.MethodName. Using just the class and method name, or using the namespace instead of the assembly name, will silently fail.

Returning complex types directly If you try to return a record or class from a [JSExport] method, you will get a marshalling error. Always serialise to a JSON string and let the WebWorkerClient deserialise on the other side.

Creating workers in a loop Each WebWorkerClient.CreateAsync call boots a full .NET runtime in a new worker thread. Creating workers inside a foreach loop or on every button click will exhaust browser memory rapidly. Create once, reuse often, dispose when done.

Debugging breakpoints Setting breakpoints in worker code while debugging can cause the worker initialisation to hang indefinitely. This is a known issue tracked in the ASP.NET Core repository. If your worker seems stuck during development, check whether you have breakpoints set inside [JSExport] methods.

Not disposing workers A WebWorkerClient that is not disposed keeps a .NET runtime alive in the background. Over time, this leaks memory. Implement IAsyncDisposable on your component and call DisposeAsync on the worker, or use await using for short-lived usage.

Summary