| title | ASP.NET Core Blazor with .NET on Web Workers |
|---|---|
| ai-usage | ai-assisted |
| author | guardrex |
| description | Learn how to use Web Workers to enable JavaScript to run on separate threads that don't block the main UI thread for improved app performance in a Blazor WebAssembly app. |
| monikerRange | >= aspnetcore-8.0 |
| ms.author | wpickett |
| ms.custom | mvc |
| ms.date | 04/07/2026 |
| uid | blazor/blazor-web-workers |
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 Blazor Web Worker project template (dotnet new blazorwebworker) provides built-in scaffolding for running .NET code in a Web Worker in a Blazor WebAssembly app. The template generates the required JavaScript worker scripts, a C# WebWorkerClient class, and a starter WorkerMethods.cs file, 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
In .NET 11 and later, the Blazor Web Worker template (blazorwebworker) is intended for Blazor WebAssembly scenarios. For React or other custom JavaScript frontends, use the manual approach in xref:client-side/dotnet-on-webworkers.
Create a Blazor WebAssembly app and a .NET Web Worker class library:
dotnet new blazorwasm -n SampleApp
dotnet new blazorwebworker -n WebWorker
Add a project reference from the app to the worker library:
cd SampleApp
dotnet add reference ../WebWorker/WebWorker.csproj
The template already enables the xref:Microsoft.Build.Tasks.Csc.AllowUnsafeBlocks property in the worker project and includes a starter WorkerMethods.cs file with a [JSExport] attribute example. Add or update methods in the worker project as needed.
Worker methods are static methods marked with [JSExport] in a static partial class.
Due to [JSExport] limitations, worker methods should use JS interop-friendly types such as primitives and strings. For complex data, serialize to JSON in the worker and deserialize it in the Blazor app.
WebWorker\WorkerMethods.cs:
using System.Runtime.InteropServices.JavaScript;
using System.Runtime.Versioning;
using System.Text.Json;
namespace WebWorker;
[SupportedOSPlatform("browser")]
public static partial class WorkerMethods
{
[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.Text.Json;
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>(
"WebWorker.WorkerMethods.Greet", ["World"]);
var usersJson = await worker.InvokeAsync<string>(
"WebWorker.WorkerMethods.GetUsers", []);
users = JsonSerializer.Deserialize<List<User>>(usersJson) ?? [];
}
public async ValueTask DisposeAsync()
{
if (worker is not null)
{
await worker.DisposeAsync();
}
}
}The dotnet new blazorwebworker template generates a class library with the following structure:
WebWorker/
├── WebWorker.csproj
├── WebWorkerClient.cs
├── WorkerMethods.cs
└── wwwroot/
├── dotnet-web-worker-client.js
└── dotnet-web-worker.js
WebWorkerClient.cs: C# client that manages worker lifecycle and communication.WorkerMethods.cs: Starter file for adding[JSExport]methods that run inside the worker.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.
The 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,
int timeoutMs = 60000,
string? assemblyName = null,
CancellationToken cancellationToken = default);
public async Task<TResult> InvokeAsync<TResult>(
string method,
object[] args,
int timeoutMs = 60000,
CancellationToken cancellationToken = default);
public async Task InvokeVoidAsync(
string method,
object[] args,
int timeoutMs = 60000,
CancellationToken cancellationToken = default);
public async ValueTask DisposeAsync();
}CreateAsync: Initializes the worker and waits for the .NET runtime to be ready inside the worker thread. TheassemblyNameparameter defaults to the worker project's assembly.InvokeAsync<TResult>: Calls a[JSExport]method on the worker by its full name (AssemblyName.ClassName.MethodName) and returns the result.InvokeVoidAsync: Calls a[JSExport]method that doesn't return a value.DisposeAsync: Terminates the worker and releases resources. Useawait usingor 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 WebWorkersOnBlazorAdd a package reference for QRCoder to simulate heavy computations.
Warning
Shane32/QRCoder/QRCoder NuGet 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 loadingAdd 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 loadingAdd 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">
<img class="image" src="@imageUrl" id="qrImage" alt="Image" />
</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);
}
}- Swap the QR code sample for your own CPU-intensive domain logic.
- Move long-running workflows into dedicated worker instances per feature area.
- Explore shared array buffers or Atomics when you need higher-throughput synchronization between Blazor and workers.
:::moniker-end
xref:client-side/dotnet-on-webworkers