Back to Aspnetcore

ASP.NET Core Blazor with .NET on Web Workers

aspnetcore/blazor/blazor-with-dotnet-on-web-workers.md

latest16.0 KB
Original Source

ASP.NET Core Blazor with .NET on Web Workers

<!-- UPDATE 11.0 - Activate [!INCLUDE[](~/includes/not-latest-version.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 webworker template isn't limited to Blazor. The template works with any .NET WebAssembly host, including standalone wasmbrowser apps 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# WebWorkerClient class.

Create the projects

Create a Blazor WebAssembly app and a .NET Web Worker class library:

dotnetcli
dotnet new blazorwasm -n SampleApp
dotnet new webworker -n WebWorker

Add a project reference from the app to the worker library:

dotnetcli
cd SampleApp
dotnet add reference ../WebWorker/WebWorker.csproj

Enable AllowUnsafeBlocks

Enable the xref:Microsoft.Build.Tasks.Csc.AllowUnsafeBlocks property in the app's project file (SampleApp.csproj), which is required for [JSExport] attribute usage:

xml
<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.

Define worker methods

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:

csharp
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);

Use the worker from a component

Inject IJSRuntime and use WebWorkerClient.CreateAsync to create a worker instance. The client manages the JavaScript messaging layer on your behalf.

Pages/Home.razor:

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:

csharp
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();
        }
    }
}

Template output

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 API

The WebWorkerClient class exposes an async API for communicating with a Web Worker:

csharp
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.

Sample app

Explore a complete working implementation in the Blazor samples GitHub repository. The sample is available for .NET 10 or later and named DotNetOnWebWorkersBlazorWebAssembly.

Prerequisites

Before diving into the implementation, ensure the necessary tools are installed. The .NET SDK 8.0 or later is required.

Create the Blazor WebAssembly project

Create a Blazor WebAssembly app:

bash
dotnet new blazorwasm -o WebWorkersOnBlazor
cd WebWorkersOnBlazor

Add a package reference for QRCoder to simulate heavy computations.

[!INCLUDE]

[!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:

xml
<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.

Add the Web Worker code

Create the following file to expose .NET code to JavaScript using the [JSExport] attribute:

Workers/QRGenerator.razor.cs:

csharp
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:

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:

javascript
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 });
  }
});

Bridge the worker to the Blazor UI

Create the following JavaScript file that manages the worker instance and exposes helper functions to Blazor.

Clients/Client.razor.js:

javascript
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:

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:

csharp
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);
}

Demonstrate the flow

You can use the app's Home page to demonstrate the flow.

Pages/Home.razor:

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:

csharp
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);
    }
}

Next steps

  • 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

Additional resources

xref:client-side/dotnet-on-webworkers