Edit

.NET on Web Workers

Modern web apps often require intensive computational tasks that can block the main UI thread, leading to poor user experience. Web Workers provide a solution to this problem by enabling JavaScript (JS) to run on separate threads. With .NET WebAssembly (Wasm), you can run C# code in Web Workers, combining the performance benefits of compiled code with the non-blocking execution model of background threads.

This approach is particularly valuable when you need to perform complex calculations, data processing, or business logic without requiring direct DOM manipulation. Instead of rewriting algorithms in JS, you can maintain your existing .NET codebase and execute it efficiently in the background while your React.js frontend remains responsive.

Tip

In .NET 11 or later, dotnet new blazorwebworker generates the worker scripts and starter [JSExport] code used by the Blazor integration. For the Blazor-specific walkthrough, see ASP.NET Core Blazor with .NET on Web Workers.

Tip

For earlier versions, follow the manual wasmbrowser steps in this article. For the Blazor-specific walkthrough, see ASP.NET Core Blazor with .NET on Web Workers.

This article demonstrates the React approach using a standalone .NET WebAssembly project.

Sample app

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

Prerequisites and setup

Before diving into the implementation, ensure the necessary tools are installed.

The .NET 11 SDK or later is required for the blazorwebworker template approach.

The .NET SDK 8.0 or later is required. If the WebAssembly build tools aren't already installed, run:

dotnet workload install wasm-tools
dotnet workload install wasm-experimental

For the React.js frontend, Node.js and npm must be installed.

Create a new React app with Vite:

npm create vite@latest react-app
cd react-app
npm install

When prompted, select React and JavaScript.

Create a new React app:

npx create-react-app react-app
cd react-app

Create the .NET WebAssembly project

In .NET 11 or later, you can start from the Blazor Web Worker template and adapt it for a React host:

dotnet new blazorwebworker -o WebWorkersOnReact
cd WebWorkersOnReact
dotnet add package QRCoder

Update WebWorkersOnReact.csproj to use the WebAssembly SDK and build as a library. The template uses the Razor SDK by default because, in the Blazor scenario, the host app already provides the .NET WebAssembly runtime assets. In a React app, there isn't a Blazor host to provide that runtime bundle, so the worker project must produce its own _framework output.

Setting <OutputType>Library</OutputType> enables WebAssembly library mode. In this mode, the project produces the runtime files needed by the worker scripts but doesn't require a standalone app entry point:

<Project Sdk="Microsoft.NET.Sdk.WebAssembly">
  <PropertyGroup>
    <TargetFramework>net11.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
    <OutputType>Library</OutputType>
  </PropertyGroup>
</Project>

Delete WebWorkerClient.cs because it's specific to the Blazor integration.

Update WorkerMethods.cs with the methods that the React app should call:

using System.Runtime.InteropServices.JavaScript;
using System.Runtime.Versioning;
using QRCoder;

namespace WebWorkersOnReact;

[SupportedOSPlatform("browser")]
public static partial class WorkerMethods
{
    private static readonly int MaxQrSize = 20;

    [JSExport]
    public static byte[] Generate(string text, int qrSize)
    {
        if (qrSize >= MaxQrSize)
        {
            throw new ArgumentOutOfRangeException(
                nameof(qrSize),
                $"QR code size must be less than {MaxQrSize}.");
        }

        using var qrGenerator = new QRCodeGenerator();
        using var qrCodeData = qrGenerator.CreateQrCode(text, QRCodeGenerator.ECCLevel.Q);
        var qrCode = new BitmapByteQRCode(qrCodeData);
        return qrCode.GetGraphic(qrSize);
    }
}

Create a new WebAssembly browser project to serve as the Web Worker:

dotnet new wasmbrowser -o WebWorkersOnReact
cd WebWorkersOnReact
dotnet add package QRCoder

Modify the Program.cs file to set up the Web Worker entry point and message handling:

using System;
using System.Runtime.InteropServices.JavaScript;
using QRCoder;
using System.Linq;

public partial class QRGenerator
{
    private static readonly int MAX_QR_SIZE = 20;

    [JSExport]
    internal static byte[] Generate(string text, int qrSize)
    {
        if (qrSize >= MAX_QR_SIZE)
        {
            throw new Exception(
                $"QR code size must be less than {MAX_QR_SIZE}. Try again.");
        }
        QRCodeGenerator qrGenerator = new QRCodeGenerator();
        QRCodeData qrCodeData = qrGenerator.CreateQrCode(
            text, QRCodeGenerator.ECCLevel.Q);
        BitmapByteQRCode qrCode = new BitmapByteQRCode(qrCodeData);
        return qrCode.GetGraphic(qrSize);
    }
}

Add a wwwroot/worker.js file with code that interops between C# and JS:

import { dotnet } from './_framework/dotnet.js'

let assemblyExports = null;
let startupError = undefined;

try {
  const { getAssemblyExports, getConfig } = await dotnet.create();
  const config = getConfig();
  assemblyExports = await getAssemblyExports(config.mainAssemblyName);
}
catch (err) {
  startupError = err.message;
}

self.addEventListener('message', async function(e) {
  try {
    if (!assemblyExports) {
      throw new Error(startupError || "worker exports not loaded");
    }
    let result = null;
    switch (e.data.command) {
      case "generateQR":
        const size = Number(e.data.size);
        const text = e.data.text;
        if (size === undefined || text === undefined)
          new Error("Inner error, got empty QR generation data from React");
        result = assemblyExports.QRGenerator.Generate(text, 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,
    });
  }
}, false);

Build the worker project:

dotnet build

Set up the React app

For a quick test, copy the worker output into the React app's static files manually. For a real app, automate these copies with an npm script or another build step.

Use the generated JavaScript client to host the .NET WebAssembly runtime in a Web Worker. For example:

  • Copy WebWorkersOnReact/bin/Debug/net11.0/wwwroot/_framework to react-app/public/_framework.
  • Copy WebWorkersOnReact/wwwroot/dotnet-web-worker.js to react-app/public/_content/WebWorkersOnReact/dotnet-web-worker.js.
  • Copy WebWorkersOnReact/wwwroot/dotnet-web-worker-client.js to react-app/public/_content/WebWorkersOnReact/dotnet-web-worker-client.js.

Then create a client helper, such as src/client.js, to load the generated client and call the worker:

let worker;

async function getWorker() {
  if (!worker) {
    const { create } = await import(
      /* @vite-ignore */ '/_content/WebWorkersOnReact/dotnet-web-worker-client.js');
    worker = await create(60000, { assemblyName: 'WebWorkersOnReact' });
  }

  return worker;
}

export async function generateQR(text, size) {
  const worker = await getWorker();
  const response = await worker.invoke(
    'WebWorkersOnReact.WorkerMethods.Generate',
    [text, size],
    60000);
  const blob = new Blob([response], { type: 'image/png' });
  return URL.createObjectURL(blob);
}

Replace the starter content in src/App.jsx with a button that calls generateQR:

import { useState } from 'react';
import './App.css';
import { generateQR } from './client';

function App() {
  const [qrUrl, setQrUrl] = useState('');
  const [status, setStatus] = useState('Ready');

  async function handleGenerate() {
    try {
      setStatus('Generating QR code...');
      const url = await generateQR('Hello from docs', 10);
      setQrUrl(url);
      setStatus('Done');
    } catch (error) {
      setStatus(error.message);
    }
  }

  return (
    <main>
      <h1>.NET on Web Workers</h1>
      <p>{status}</p>
      <button onClick={handleGenerate}>Generate QR</button>
      {qrUrl ? <img src={qrUrl} alt="Generated QR code" /> : null}
    </main>
  );
}

export default App;

Create a Web Worker to host the .NET WebAssembly runtime. The sample app copies the entire output folder into public/qr, which preserves both wwwroot/worker.js and the _framework assets required by the runtime. See the sample app for reference.

Create a Web Worker file client.js to receive messages from dotnet:

const dotnetWorker = new Worker('../../qr/wwwroot/worker.js', { type: "module" } );

dotnetWorker.addEventListener('message', async function (e) {
  switch (e.data.command) {
    case "response":
      if (!e.data.requestId) {
        console.error("No requestId in response from worker");
      }
      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);
      break;
  }
}, false);

Connect this functionality with UI and add a button that triggers generateQR:

export async function generateQR(text, size) {
  const response = await sendRequestToWorker({
    command: "generateQR",
    text: text,
    size: size
  });
  const blob = new Blob([response], { type: 'image/png' });
  return URL.createObjectURL(blob);
}

function sendRequestToWorker(request) {
  pendingRequestId++;
  const promise = new Promise((resolve, reject) => {
    pendingRequests[pendingRequestId] = { resolve, reject };
  });
  dotnetWorker.postMessage({
    ...request,
    requestId: pendingRequestId
  });
  return promise;
}

Run the app:

npm run dev

Open the local URL shown by the development server and select Generate QR. If everything is set up correctly, the page displays the generated image.

Performance considerations and optimization

When working with .NET on Web Workers, consider these key optimization strategies:

  • Minimize data transfer: Serialize only essential data between the main thread and worker to reduce communication overhead.
  • Batch operations: Group multiple calculations together rather than sending individual requests.
  • Memory management: Be mindful of memory usage in the WebAssembly environment, especially for long-running workers.
  • Startup cost: WebAssembly initialization has overhead, so prefer persistent workers over frequent creation/destruction.

See the sample app for a demonstration of the preceding concepts.

Additional resources

ASP.NET Core Blazor with .NET on Web Workers