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:
WebWorkerClient.cs-- the C# client that manages the worker lifecycle and message dispatch.dotnet-web-worker-client.js-- creates the worker, sends messages, and resolves pending requests on the main thread.dotnet-web-worker.js-- the worker entry point that boots a second .NET WebAssembly runtime and dynamically resolves[JSExport]methods by name.
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:
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:
<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]:
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:
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:
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();
}
CreateAsyncboots a Web Worker, loads the .NET runtime inside it, and waits until the runtime is ready. This is the expensive call -- expect a noticeable delay on first invocation as the runtime initialises in the worker thread.InvokeAsync<TResult>dispatches a call to a[JSExport]method and returns the deserialised result. JSON string results are automatically parsed intoTResult.DisposeAsyncterminates the worker and frees resources. Always dispose your workers -- leaked workers mean leaked .NET runtimes.
// 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:
- You are doing CPU-intensive computation (image processing, data crunching, cryptographic operations)
- The work would visibly freeze the UI if run on the main thread
- You need genuine parallelism, not just async yielding
Stick with BackgroundService / async patterns when:
- You are polling an API endpoint
- You are waiting on HTTP responses or SignalR messages
- The work is I/O-bound and
async/awaithandles it without blocking the thread
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:
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
- .NET 11 Preview 2 introduces
dotnet new webworker, a first-party template for running .NET code in browser Web Workers. - The template scaffolds a
WebWorkerClientclass and the JavaScript plumbing, removing the need for manual interop. - Worker methods must be
staticin astatic partial class, marked with[JSExport], and can only return primitives or strings. - Use Web Workers for CPU-bound work that would freeze the UI. Stick with
async/awaitfor I/O-bound tasks. - Create workers once and reuse them -- the runtime boot cost makes per-request creation impractical.
- The template works beyond Blazor with any .NET WebAssembly host, including React and vanilla JavaScript frontends.
- Always dispose your workers to avoid leaking .NET runtime instances.