Web Workers no Blazor com .NET 11
Web Workers no Blazor ganharam um template oficial no .NET 11, que simplifica a execução de tarefas intensivas de CPU sem comprometer a responsividade da interface. Neste artigo, você vai entender como esse mecanismo funciona, como integrá-lo a uma aplicação Blazor WebAssembly e o que acontece nos bastidores quando o código C# roda em outra thread.
O problema da thread única no Blazor WebAssembly
O Blazor WebAssembly executa código .NET diretamente no navegador por meio do runtime WebAssembly. Essa abordagem traz vantagens de portabilidade e desempenho, mas carrega uma limitação estrutural importante.
O motor de JavaScript da página trabalha com uma única thread principal. Por isso, qualquer operação pesada executada nela, como processar imagens, interpretar documentos ou manipular grandes volumes de dados, bloqueia completamente a interface.
Quando a UI fica sem resposta por muito tempo, o navegador pode até exibir um aviso sugerindo o encerramento da página. Em uma aplicação de produção, essa experiência é inaceitável.
Se você ainda está se familiarizando com o framework, o artigo uma visão geral do Blazor ajuda a situar o modelo WebAssembly antes de seguir.
O que são Web Workers e como eles resolvem o problema
Web Workers são uma API nativa dos navegadores modernos que executa scripts em threads de background, isoladas da thread principal da UI. A documentação da MDN sobre Web Workers descreve esse modelo em detalhes.
A comunicação entre a página e o worker acontece por troca de mensagens assíncronas. Em vez de compartilhar variáveis, cada lado envia dados com postMessage e escuta o evento message.
Esse modelo é cooperativo e explícito. No .NET tradicional, o Task.Run() agenda trabalho no thread pool de forma quase transparente, enquanto no navegador você precisa decidir conscientemente o que roda em background.
Desde o .NET 8, já era possível usar Web Workers no Blazor, mas o processo envolvia muitas partes móveis e bastante configuração manual. Era preciso escrever o script do worker, o cliente JavaScript, o protocolo de mensagens e a interop com o .NET.
O .NET 11 resolve esse atrito com um template dedicado. Ele gera toda essa infraestrutura e entrega uma API C# pronta para uso.
O template de Web Workers no Blazor com .NET 11
O template apareceu no .NET 11 Preview 2 com o nome curto webworker. Em uma versão posterior do preview, a Microsoft o renomeou para Blazor Web Worker (blazorwebworker), para deixar claro que ele faz parte da stack do Blazor.
Segundo as notas de lançamento do ASP.NET Core no .NET 11, projetos criados com o nome antigo continuam funcionando. A mudança afeta apenas o nome exibido no dotnet new list e na lista de templates do Visual Studio.
A abordagem do template difere das soluções anteriores. Em vez de espalhar scripts pela aplicação, ele cria uma biblioteca de classes separada que a aplicação Blazor referencia, o que mantém uma separação clara de responsabilidades.
Criando os projetos
Com o nome atual do template, os comandos ficam assim:
# Cria a solução e a aplicação Blazor WebAssembly
dotnet new sln
mkdir src
dotnet new blazorwasm -o ./src/BlazorWebApp
dotnet sln add ./src/BlazorWebApp
# Cria a biblioteca do Web Worker e adiciona à solução
dotnet new blazorwebworker -o ./src/BlazorWebWorker
dotnet sln add ./src/BlazorWebWorker
# A aplicação passa a referenciar o projeto do worker
dotnet add ./src/BlazorWebApp reference ./src/BlazorWebWorker
No .NET 11 Preview 2, o comando de criação usava dotnet new webworker. Fora o nome, o fluxo de criação e referência permanece o mesmo.
AllowUnsafeBlocks e o atributo JSExport
O atributo [JSExport] exige a propriedade AllowUnsafeBlocks, porque o gerador de código do Roslyn usa ponteiros na interop com JavaScript. Na versão atual, o template já habilita essa propriedade no projeto do worker.
No Preview 2, porém, os exemplos colocavam os métodos exportados na própria aplicação Blazor. Nesse caso, você precisava habilitar a propriedade no arquivo de projeto da aplicação:
<PropertyGroup>
<!-- Necessário sempre que o projeto usa [JSExport] ou [JSImport] -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
A documentação alerta que essa propriedade não deve ser uma porta de entrada para código inseguro próprio. Ela existe para o código gerado pela interop, e não para o seu código de negócio.
Exportando métodos para Web Workers no Blazor
Todo o código que você executa com Web Workers no Blazor fica em métodos marcados com [JSExport]. Esses métodos seguem algumas restrições.
Primeiro, eles são static e ficam dentro de uma classe static partial.
Além disso, a classe recebe o atributo [SupportedOSPlatform("browser")], porque a interop com JavaScript só existe no contexto do navegador. Por fim, os tipos de retorno devem ser amigáveis à interop, como primitivos e string.
Objetos complexos, portanto, precisam ser serializados para JSON antes de voltar. O exemplo a seguir, no arquivo WorkerMethods.cs do projeto do worker, deixa isso claro:
using System.Runtime.InteropServices.JavaScript;
using System.Runtime.Versioning;
using System.Text.Json;
namespace BlazorWebWorker;
[SupportedOSPlatform("browser")]
public static partial class WorkerMethods
{
[JSExport]
public static string GetForecasts(int count)
{
// Simula um trabalho pesado que travaria a UI
Thread.Sleep(5_000);
var forecasts = new WeatherForecast[]
{
new() { Date = new DateOnly(2022, 01, 06), TemperatureC = 1, Summary = "Freezing" },
new() { Date = new DateOnly(2022, 01, 07), TemperatureC = 14, Summary = "Bracing" }
};
// Objetos complexos voltam serializados como JSON
return JsonSerializer.Serialize(forecasts.Take(count));
}
}
// Tipo compartilhado: a aplicação Blazor referencia este projeto
public class WeatherForecast
{
public DateOnly Date { get; set; }
public int TemperatureC { get; set; }
public string? Summary { get; set; }
}
O Thread.Sleep ilustra exatamente o ponto central da feature. Esse bloqueio de cinco segundos tornaria a aplicação irresponsiva na thread principal, mas no worker a UI continua fluida.
Invocando Web Workers no Blazor a partir de um componente
A classe WebWorkerClient é o ponto de entrada para conversar com o worker a partir do código .NET. Ela encapsula toda a comunicação com o JavaScript gerado pelo template.
O uso dentro de um componente é direto:
@using BlazorWebWorker
@inject IJSRuntime JsRuntime
@code {
private WeatherForecast[]? forecasts;
protected override async Task OnInitializedAsync()
{
// Cria o worker e aguarda o runtime .NET ficar pronto nele
await using var worker = await WebWorkerClient.CreateAsync(JsRuntime);
const string workerMethod = $"{nameof(BlazorWebWorker)}.{nameof(WorkerMethods)}.{nameof(WorkerMethods.GetForecasts)}";
// O resultado em JSON é desserializado para o tipo informado
forecasts = await worker.InvokeAsync<WeatherForecast[]>(workerMethod, args: [5]);
}
}
O método InvokeAsync<T> desserializa automaticamente a string retornada pelo método exportado. Assim, o componente recebe diretamente um WeatherForecast[].
Repare no nome do método. Ele segue o formato AssemblyName.ClassName.MethodName, e o uso de nameof evita erros de digitação quando você renomeia algo.
Nos exemplos do Preview 2, os métodos ficavam na própria aplicação, então o caminho começava com BlazorWebApp. Com os métodos no projeto do worker, o caminho começa com BlazorWebWorker.
Na documentação atual, o exemplo do Microsoft Learn cria o worker no OnAfterRenderAsync e o descarta no DisposeAsync do componente. Essa abordagem combina bem com o ciclo de vida descrito no artigo sobre render modes do Blazor.
A API do WebWorkerClient
Depois do Preview 2, a Microsoft adicionou dois recursos muito pedidos ao WebWorkerClient. O primeiro é o InvokeVoidAsync, para chamadas que não retornam valor, no mesmo formato do IJSRuntime.
O segundo é o suporte a cancelamento e timeout, tanto na criação quanto nas invocações. Com isso, você passa um CancellationToken e encerra um worker travado de forma limpa.
A assinatura documentada no Microsoft Learn ficou assim:
public sealed class WebWorkerClient : IAsyncDisposable
{
// Inicializa o worker e espera o runtime .NET ficar pronto
public static async Task<WebWorkerClient> CreateAsync(
IJSRuntime jsRuntime,
int timeoutMs = 60000,
string? assemblyName = null,
CancellationToken cancellationToken = default);
// Chama um método [JSExport] e devolve o resultado
public async Task<TResult> InvokeAsync<TResult>(
string method,
object[] args,
int timeoutMs = 60000,
CancellationToken cancellationToken = default);
// Chama um método [JSExport] sem retorno
public async Task InvokeVoidAsync(
string method,
object[] args,
int timeoutMs = 60000,
CancellationToken cancellationToken = default);
// Encerra o worker e libera os recursos
public async ValueTask DisposeAsync();
}
Na prática, essas adições deixam os Web Workers no Blazor mais seguros em produção. Um worker que nunca responde deixa de prender a aplicação para sempre.
O parâmetro assemblyName assume, por padrão, o assembly do projeto do worker. Portanto, você só precisa informá-lo quando os métodos exportados vivem em outro assembly.
O custo de inicialização dos Web Workers no Blazor
Um aspecto crítico de desempenho dos Web Workers no Blazor é o custo de inicialização. Cada instância precisa subir o próprio runtime .NET, totalmente isolado da aplicação principal.
Isso inclui baixar e carregar os assemblies necessários. Por isso, o tempo de inicialização pode ser perceptível para o usuário, sobretudo na primeira chamada.
A solução recomendada é manter a instância do WebWorkerClient em cache e reutilizá-la entre chamadas:
private static WebWorkerClient? _client;
public static async Task<WebWorkerClient> GetOrCreateClient(IJSRuntime jsRuntime)
{
// Reaproveita o worker que já foi inicializado
if (_client is { } client)
{
return client;
}
_client = await WebWorkerClient.CreateAsync(jsRuntime);
return _client;
}
Outra opção é registrar um serviço próprio no contêiner de injeção de dependência que guarde essa instância. Assim, qualquer componente acessa o mesmo worker sem repetir a inicialização.
Com essa abordagem, você paga o custo de inicialização uma única vez por sessão. Em seguida, as chamadas subsequentes ao worker têm latência mínima.
Arquitetura interna dos Web Workers no Blazor
Para entender completamente o mecanismo, vale examinar os arquivos que o template gera. Segundo o Microsoft Learn, a biblioteca tem a seguinte estrutura:
BlazorWebWorker/
├── BlazorWebWorker.csproj
├── WebWorkerClient.cs
├── WorkerMethods.cs
└── wwwroot/
├── dotnet-web-worker-client.js
└── dotnet-web-worker.js
Conhecer esses arquivos ajuda a depurar Web Workers no Blazor quando algo não sai como esperado.
O WorkerMethods.cs é apenas um arquivo inicial para os seus métodos com [JSExport]. Os outros três arquivos formam a ponte entre o C# e a thread de background.
WebWorkerClient.cs
No lado .NET, a classe WebWorkerClient é relativamente simples. Ela importa o módulo dotnet-web-worker-client.js via interop, chama create() para instanciar o objeto JavaScript e delega as invocações a ele.
O DisposeAsync, por sua vez, chama terminate() no worker quando ele não é mais necessário. Por isso, vale usar await using ou descartar a instância explicitamente.
dotnet-web-worker-client.js
Esse arquivo atua como uma fábrica de workers e gerencia a comunicação bidirecional com eles. A classe DotnetWebWorkerClient mantém um dicionário de requisições pendentes, associando cada chamada a um identificador único.
Quando o resultado chega pelo evento message, o cliente localiza a requisição correspondente e resolve a promise. Dessa forma, várias chamadas podem ficar em andamento ao mesmo tempo sem se misturar.
Um detalhe importante é a resolução da URL do runtime .NET. Workers não herdam o import map da página principal, então a thread principal resolve a URL do dotnet.js e a envia explicitamente ao worker:
static #resolveDotnetJsUrl() {
// Resolve a URL na thread principal, onde o import map existe
const dotnetJsUrl = new URL('_framework/dotnet.js', document.baseURI).href;
return import.meta.resolve?.(dotnetJsUrl) ?? dotnetJsUrl;
}
Isso é especialmente relevante em builds de produção. Nesses builds, os arquivos recebem fingerprints no nome para controle de cache.
dotnet-web-worker.js
Esse é o código que efetivamente roda dentro do worker. Ele inicializa um runtime .NET completo com o dotnet.js, carrega os exports do assembly via getAssemblyExports e fica aguardando mensagens do tipo invoke.
Quando uma invocação chega, o script percorre o objeto de exports usando o nome completo do método como caminho de propriedades aninhadas. Por exemplo, BlazorWebWorker.WorkerMethods.GetForecasts vira uma navegação por BlazorWebWorker, depois WorkerMethods e, por fim, GetForecasts.
Em seguida, o resultado volta para a thread principal com postMessage, junto com o identificador da requisição. Assim, o chamador correto recebe a resposta.
Esse ciclo completo explica por que os Web Workers no Blazor exigem tipos simples na fronteira. Tudo o que cruza entre as threads viaja dentro de mensagens.
Limitações e cuidados com Web Workers no Blazor
O modelo de Web Workers no Blazor é cooperativo e explícito por design. Diferentemente do .NET no servidor, onde async/await e Task.Run() lidam com threads de forma transparente, aqui cada execução em background precisa ser agendada deliberadamente.
Os métodos exportados com [JSExport] também têm restrições de tipo. Como apenas primitivos e strings voltam diretamente, objetos complexos sempre exigem serialização e desserialização.
O template cria um projeto separado, o que traz clareza arquitetural, mas acrescenta um projeto à solução. Em cenários com vários tipos de workers, isso pode levar a uma proliferação de projetos.
Para dados volumosos, vale considerar os Transferable Objects, como ArrayBuffer e views tipadas. Nesse caso, o navegador transfere a posse do buffer sem copiá-lo, e o template já identifica esses objetos com a função collectTransferables.
Por fim, lembre que o .NET 11 ainda estava em preview quando o template surgiu. Inclusive, o próprio nome do template mudou no caminho, então detalhes da implementação podem mudar até a versão final.
Quando usar Web Workers no Blazor
Usar Web Workers no Blazor faz sentido quando o processamento é pesado o bastante para degradar a UI de forma perceptível. Entre os casos típicos estão:
- Processamento de imagens ou vídeo no cliente, em que operações sobre buffers de pixels levam segundos.
- Interpretação e transformação de documentos grandes, como arquivos CSV ou XML com milhares de linhas.
- Algoritmos de busca ou ordenação sobre grandes conjuntos de dados locais.
- Geração de relatórios ou cálculos financeiros com muitas iterações.
Por outro lado, para operações que terminam em milissegundos, o custo da troca de mensagens pode não compensar a complexidade adicional. Nesses casos, a thread principal continua sendo a melhor opção.
Também vale lembrar do Blazor Web App. Segundo o Microsoft Learn, o trabalho pesado costuma rodar no servidor, mas o uso do template no projeto .Client com renderização no cliente é suportado.
Para acompanhar as demais novidades da versão, o artigo sobre o futuro do Blazor no .NET 11 complementa este conteúdo.
Conclusão
O template de Web Workers do .NET 11 representa uma evolução importante para o Blazor WebAssembly. Ele elimina a configuração manual que antes dificultava a adoção e entrega uma abstração limpa para executar C# em background.
A combinação do atributo [JSExport], da classe WebWorkerClient e dos scripts gerados oferece uma experiência próxima do threading tradicional. Ainda assim, ela respeita as restrições do modelo de execução do navegador.
Com o .NET 11 ainda em preview, vale acompanhar as notas de lançamento, já que o template mudou de nome e ganhou novos recursos no caminho. Mesmo assim, a direção é clara: tornar os Web Workers no Blazor um caminho viável para aplicações com alta demanda de processamento no cliente.
FAQ
Por que o Blazor WebAssembly precisa de Web Workers?
Porque a página tem uma única thread principal, e qualquer operação pesada nela congela a interface. O Web Worker leva esse trabalho para uma thread de background.
Qual a diferença entre os templates webworker e blazorwebworker?
São o mesmo template. Ele surgiu como webworker no .NET 11 Preview 2 e depois foi renomeado para blazorwebworker, sem quebrar projetos já criados.
Por que os métodos com JSExport só retornam primitivos ou string?
Essa é uma limitação da interop entre JavaScript e WebAssembly. Para objetos complexos, a abordagem padrão é serializar para JSON no worker e desserializar no Blazor.
Como evitar o custo de inicialização a cada chamada?
Mantenha a instância do WebWorkerClient em um campo estático ou em um serviço injetado e reutilize-a. Assim, o runtime .NET do worker sobe apenas uma vez.
Posso ter vários Web Workers ao mesmo tempo?
Sim. Cada WebWorkerClient corresponde a um worker independente com o próprio runtime .NET, mas cada um consome memória e tempo de inicialização.
O template funciona com Blazor Server?
Não. Web Workers são uma API do navegador e só fazem sentido quando o código .NET roda no cliente, como no Blazor WebAssembly ou no projeto .Client de um Blazor Web App.