aspnetcore/blazor/blazor-with-dotnet-on-web-workers.md
Modern Blazor WebAssembly apps often handle CPU-intensive work alongside rich UI updates. Tasks such as image processing, document parsing, or data crunching can easily freeze the browser's main thread. Web Workers let you push that work to a background thread. Combined with the .NET WebAssembly runtime, you can keep writing C# while the UI stays responsive.
:::moniker range=">= aspnetcore-11.0"
The webworker project template provides built-in scaffolding for running .NET code in a Web Worker. The template generates the required JavaScript worker scripts and a C# WebWorkerClient class, which removes the need to write the interop layer manually. To learn about Web Workers with React, see xref:client-side/dotnet-on-webworkers.
[!NOTE] The
webworkertemplate isn't limited to Blazor. The template works with any .NET WebAssembly host, including standalonewasmbrowserapps and custom JavaScript frontends, such as React or vanilla JS. In non-Blazor scenarios, import the template's JavaScript client (dotnet-web-worker-client.js) directly from your entry point and call[JSExport]methods without the Blazor-specific C#WebWorkerClientclass.
Create a Blazor WebAssembly app and a .NET Web Worker class library:
dotnet new blazorwasm -n SampleApp
dotnet new webworker -n WebWorker
Add a project reference from the app to the worker library:
cd SampleApp
dotnet add reference ../WebWorker/WebWorker.csproj
AllowUnsafeBlocksEnable the xref:Microsoft.Build.Tasks.Csc.AllowUnsafeBlocks property in the app's project file (SampleApp.csproj), which is required for [JSExport] attribute usage:
<PropertyGroup>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
[!WARNING] The JS interop API requires enabling xref:Microsoft.Build.Tasks.Csc.AllowUnsafeBlocks. Be careful when implementing your own unsafe code in .NET apps, which can introduce security and stability risks. For more information, see Unsafe code, pointer types, and function pointers.
Worker methods are static methods marked with [JSExport] in a static partial class. Define them in the main application project because the assembly name must match the one used by the worker runtime.
Due to [JSExport] limitations, worker methods can only return primitives or strings. For complex types, serialize to JSON before returning. The WebWorkerClient automatically deserializes JSON results.
Worker.cs:
using System.Runtime.InteropServices.JavaScript;
using System.Runtime.Versioning;
using System.Text.Json;
[SupportedOSPlatform("browser")]
public static partial class Worker
{
[JSExport]
public static string Greet(string name) => $"Hello, {name}!";
[JSExport]
public static string GetUsers()
{
var users = new List<User> { new("Alice", 30), new("Bob", 25) };
return JsonSerializer.Serialize(users);
}
}
public record User(string Name, int Age);
Inject IJSRuntime and use WebWorkerClient.CreateAsync to create a worker instance. The client manages the JavaScript messaging layer on your behalf.
Pages/Home.razor:
@page "/"
@using WebWorker
@implements IAsyncDisposable
@inject IJSRuntime JSRuntime
<PageTitle>Home</PageTitle>
<h1>Web Worker demo</h1>
<button class="btn btn-primary" @onclick="CallWorker" disabled="@(worker is null)">
Call Worker
</button>
@if (!string.IsNullOrEmpty(greeting))
{
<p>@greeting</p>
}
@if (users is not null)
{
<ul>
@foreach (var user in users)
{
<li>@user.Name (age @user.Age)</li>
}
</ul>
}
Pages/Home.razor.cs:
using System.Runtime.Versioning;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using WebWorker;
namespace SampleApp.Pages;
[SupportedOSPlatform("browser")]
public partial class Home : ComponentBase, IAsyncDisposable
{
private WebWorkerClient? worker;
private string greeting = string.Empty;
private List<User>? users;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
worker = await WebWorkerClient.CreateAsync(JSRuntime);
StateHasChanged();
}
}
private async Task CallWorker()
{
if (worker is null)
{
return;
}
greeting = await worker.InvokeAsync<string>(
"SampleApp.Worker.Greet", ["World"]);
users = await worker.InvokeAsync<List<User>>(
"SampleApp.Worker.GetUsers", []);
}
public async ValueTask DisposeAsync()
{
if (worker is not null)
{
await worker.DisposeAsync();
}
}
}
The dotnet new webworker template generates a class library with the following structure:
WebWorker/
├── WebWorker.csproj
├── WebWorkerClient.cs
└── wwwroot/
├── dotnet-web-worker-client.js
└── dotnet-web-worker.js
WebWorkerClient.cs: C# client that manages worker lifecycle and communication.dotnet-web-worker-client.js: JavaScript class that creates the worker, dispatches messages, and resolves pending requests.dotnet-web-worker.js: Worker entry point that boots the .NET WebAssembly runtime and dynamically resolves [JSExport] methods by name.WebWorkerClient APIThe WebWorkerClient class exposes an async API for communicating with a Web Worker:
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();
}
CreateAsync: Initializes the worker and waits for the .NET runtime to be ready inside the worker thread.InvokeAsync<TResult>: Calls a [JSExport] method on the worker by its full name (Namespace.ClassName.MethodName) and returns the deserialized result. JSON string results are automatically parsed into TResult.DisposeAsync: Terminates the worker and releases resources. Use await using or call explicitly.:::moniker-end
:::moniker range="< aspnetcore-11.0"
The guidance in this article mirrors the concepts from the React-focused .NET on Web Workers walkthrough, but adapts every step to a Blazor frontend. It highlights the same QR-code generation scenario implemented in this repository. To learn about Web Workers with React, see xref:client-side/dotnet-on-webworkers.
Explore a complete working implementation in the Blazor samples GitHub repository. The sample is available for .NET 10 or later and named DotNetOnWebWorkersBlazorWebAssembly.
Before diving into the implementation, ensure the necessary tools are installed. The .NET SDK 8.0 or later is required.
Create a Blazor WebAssembly app:
dotnet new blazorwasm -o WebWorkersOnBlazor
cd WebWorkersOnBlazor
Add a package reference for QRCoder to simulate heavy computations.
[!WARNING]
Shane32/QRCoder/QRCoderNuGet package isn't owned or maintained by Microsoft and isn't covered by any Microsoft Support Agreement or license. Use caution when adopting a third-party library, especially for security features. Confirm that the library follows official specifications and adopts security best practices. Keep the library's version current to obtain the latest bug fixes.
Enable the xref:Microsoft.Build.Tasks.Csc.AllowUnsafeBlocks property in app's project file, which is required whenever you use [JSImport] attribute or [JSExport] attribute in WebAssembly projects:
<PropertyGroup>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
[!WARNING] The JS interop API requires enabling xref:Microsoft.Build.Tasks.Csc.AllowUnsafeBlocks. Be careful when implementing your own unsafe code in .NET apps, which can introduce security and stability risks. For more information, see Unsafe code, pointer types, and function pointers.
Create the following file to expose .NET code to JavaScript using the [JSExport] attribute:
Workers/QRGenerator.razor.cs:
using System.Runtime.InteropServices.JavaScript;
using System.Runtime.Versioning;
using QRCoder;
[SupportedOSPlatform("browser")]
public partial class QRGenerator
{
private static readonly int MaxQrSize = 20;
[JSExport]
internal static byte[] Generate(string text, int qrSize)
{
if (qrSize >= MaxQrSize)
{
throw new Exception($"QR code size must be less than {MaxQrSize}.");
}
var generator = new QRCodeGenerator();
QRCodeData data = generator.CreateQrCode(text, QRCodeGenerator.ECCLevel.Q);
var qrCode = new BitmapByteQRCode(data);
return qrCode.GetGraphic(qrSize);
}
}
Create a matching Razor component file (.razor) to act as an empty stub so that the build packs the worker script alongside the component assets:
Workers/QRGenerator.razor:
// dummy file to let blazor handle Worker.razor.js file loading
Add the following JavaScript file. The script boots the .NET runtime in the worker, then listens for messages from the main thread. postMessage is used to send either a result or an error payload.
Workers/QRGenerator.razor.js:
import { dotnet } from '../_framework/dotnet.js';
let assemblyExports;
let startupError;
try {
const { getAssemblyExports, getConfig } = await dotnet.create();
const config = getConfig();
assemblyExports = await getAssemblyExports(config.mainAssemblyName);
} catch (err) {
startupError = err.message;
}
self.addEventListener('message', async e => {
try {
if (!assemblyExports) {
throw new Error(startupError || 'worker exports not loaded');
}
let result;
switch (e.data.command) {
case 'generateQR':
result = assemblyExports.QRGenerator.Generate(e.data.text, e.data.size);
break;
default:
throw new Error(`Unknown command: ${e.data.command}`);
}
self.postMessage({ command: 'response',
requestId: e.data.requestId, result });
} catch (err) {
self.postMessage({ command: 'response',
requestId: e.data.requestId, error: err.message });
}
});
Create the following JavaScript file that manages the worker instance and exposes helper functions to Blazor.
Clients/Client.razor.js:
const pendingRequests = {};
let pendingRequestId = 0;
const dotnetWorker =
new Worker('./Workers/QRGenerator.razor.js', { type: 'module' });
dotnetWorker.addEventListener('message', e => {
switch (e.data.command) {
case 'response':
const request = pendingRequests[e.data.requestId];
delete pendingRequests[e.data.requestId];
if (e.data.error) {
request.reject(new Error(e.data.error));
}
request.resolve(e.data.result);
break;
default:
console.log('Worker said:', e.data);
}
});
function sendRequestToWorker(request) {
pendingRequestId++;
const promise = new Promise((resolve, reject) => {
pendingRequests[pendingRequestId] = { resolve, reject };
});
dotnetWorker.postMessage({ ...request, requestId: pendingRequestId });
return promise;
}
export async function generateQR(text, size) {
const response = await sendRequestToWorker({ command: 'generateQR', text, size });
const blob = new Blob([response], { type: 'image/png' });
return URL.createObjectURL(blob);
}
Similarly as the worker, the Client script requires a matching .razor file with an empty stub to assure that the JS file is considered a part of the component.
Clients/Client.razor:
// dummy file to let blazor handle Client.razor.js file loading
Add the following Client, which exposes the JavaScript module to Blazor components using the [JSImport] attribute. InitClient ensures the worker JS module is only loaded once per browser session.
Clients/Client.razor.cs:
using System.Runtime.InteropServices.JavaScript;
using System.Runtime.Versioning;
[SupportedOSPlatform("browser")]
public partial class Client
{
private static bool _workerStarted;
public static async Task InitClient()
{
if (_workerStarted)
{
return;
}
_workerStarted = true;
await JSHost.ImportAsync(
moduleName: nameof(Client),
moduleUrl: "../Clients/Client.razor.js");
}
[JSImport("generateQR", nameof(Client))]
public static partial Task<string> GenerateQR(string text, int size);
}
You can use the app's Home page to demonstrate the flow.
Pages/Home.razor:
@page "/"
@using Components
@namespace Pages
<PageTitle>Home</PageTitle>
<h1>Hello, world!</h1>
<Popup @ref="popup" />
<div class="input-container">
<div class="form-group">
<label for="textInput">Generate a QR from text:</label>
<input type="text" class="form-control" id="textInput" @bind="text" placeholder="Text" />
</div>
<div class="form-group">
<label for="numberInput">Set size of QR (in pixels):</label>
<input type="number" class="form-control" id="numberInput" @bind="size" />
</div>
<div class="form-group">
<button class="btn btn-primary" @onclick="GenerateQR">Generate QR</button>
</div>
@if (!string.IsNullOrWhiteSpace(imageUrl))
{
<div class="form-group">
</div>
}
</div>
The following code-behind file initializes the client and generates the QR code. The OnAfterRenderAsync lifecycle method code guarantees that the JavaScript module is loaded before the user clicks the button, while the GenerateQR handler makes a single asynchronous worker request.
Home.razor.cs:
using Microsoft.AspNetCore.Components;
using System.Runtime.Versioning;
using Components;
namespace Pages;
[SupportedOSPlatform("browser")]
public partial class Home : ComponentBase
{
private string imageUrl = string.Empty;
private string? text;
private int size = 5;
private Popup popup = new();
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await Client.InitClient();
}
private async Task GenerateQR()
{
try
{
if (text is not null)
{
imageUrl = await Client.GenerateQR(text, size);
}
}
catch(Exception ex)
{
imageUrl = string.Empty;
popup.Show(title: "Error", message: ex.Message);
}
await InvokeAsync(StateHasChanged);
}
}
:::moniker-end