Web Workers: rode JS pesado sem travar a tela

Entenda como mover processamento pesado pra fora da thread principal do navegador usando Web Worker, com exemplo de postMessage indo e voltando, e quando essa complexidade extra realmente vale a pena.


Já expliquei aqui como o event loop do Node evita travar esperando operação de entrada e saída, mas também deixei claro o limite dele: código síncrono pesado ocupa a única thread disponível e trava tudo o resto. No navegador existe exatamente o mesmo problema, só que o "tudo o resto" ali é a própria interface: botão não responde, scroll engasga, a página parece congelada enquanto aquele processamento não termina.

Onde isso aparece no frontend

function processarArquivoGigante(dados) {
  let resultado = [];
  for (let i = 0; i < dados.length; i++) {
    resultado.push(calculoComplexo(dados[i]));
  }
  return resultado;
}
 
botaoProcessar.addEventListener("click", () => {
  const resultado = processarArquivoGigante(arquivoCarregado);
  mostrarResultado(resultado);
});

Se dados tem algumas centenas de milhares de itens e calculoComplexo não é trivial, esse laço pode levar vários segundos pra terminar. Durante esse tempo inteiro, a thread principal do navegador, a mesma que desenha a tela e responde a clique, fica presa nesse processamento. O usuário clica em qualquer outro lugar da página e nada acontece, porque o navegador simplesmente não tem brecha pra processar esse clique até o laço terminar.

O que um Web Worker resolve

Um Web Worker roda um script JavaScript numa thread completamente separada da thread principal, com sua própria memória isolada. Isso permite que um processamento pesado aconteça em paralelo, sem bloquear a interface nem por um instante.

// worker.js
self.onmessage = (evento) => {
  const dados = evento.data;
  const resultado = dados.map((item) => calculoComplexo(item));
  self.postMessage(resultado);
};
// script principal
const worker = new Worker("worker.js");
 
botaoProcessar.addEventListener("click", () => {
  worker.postMessage(arquivoCarregado);
});
 
worker.onmessage = (evento) => {
  mostrarResultado(evento.data);
};

O botão de clique dispara worker.postMessage, que envia o dado pro worker e retorna imediatamente, sem esperar nada. O processamento pesado roda inteiramente dentro do worker, numa thread separada, e quando termina, ele avisa de volta com self.postMessage, entregando o resultado pro script principal através do evento onmessage. Durante todo esse tempo, a interface continua respondendo normalmente a qualquer outra interação do usuário.

O que não pode ser feito dentro de um worker

Essa separação de thread tem um custo: um worker não tem acesso direto ao DOM, não pode manipular elemento da página, não acessa window nem a maioria das APIs que dependem diretamente da tela. A comunicação entre o worker e o script principal acontece exclusivamente através de mensagens trocadas com postMessage, nunca por acesso compartilhado direto a uma mesma variável ou objeto.

Isso significa que um worker é adequado pra processamento puro de dado (cálculo, parsing, transformação), não pra qualquer lógica que precisa atualizar a tela diretamente. A atualização visual continua acontecendo no script principal, depois que o resultado volta do worker através da mensagem.

O custo de copiar dado entre threads

Por padrão, o dado trocado entre a thread principal e o worker é copiado, não compartilhado por referência. Pra a maioria dos casos isso é transparente e rápido o suficiente, mas em volumes muito grandes de dado, copiar pode se tornar um custo real por si só.

Pra esses casos existe o Transferable, como um ArrayBuffer, que transfere a posse do dado em vez de copiar ele:

worker.postMessage(arrayBuffer, [arrayBuffer]);

Depois dessa chamada, o arrayBuffer original deixa de existir no script principal, porque a posse dele foi transferida inteiramente pro worker, evitando a cópia e deixando a transferência praticamente instantânea, não importa o tamanho do dado.

Quando vale a pena essa complexidade

Web Worker não é algo pra usar em qualquer processamento um pouco mais longo. A comunicação por mensagem adiciona complexidade real ao código, e processamento rápido, de poucos milissegundos, nem chega a ser percebido como travamento, então criar um worker pra isso só adiciona overhead sem ganho perceptível.

Vale a pena quando o processamento realmente trava a interface por um tempo perceptível ao usuário: parsing de um arquivo grande enviado pelo usuário, cálculo matemático complexo em cima de muito dado, compressão de imagem no próprio navegador antes de enviar pro servidor. Nesses casos, a interface continuar respondendo durante o processamento compensa de sobra a complexidade extra de orquestrar mensagem entre as duas threads.

Fechando

Um Web Worker resolve no frontend o mesmo tipo de problema que uma fila de mensagem resolve no backend, que já expliquei aqui: tirar processamento pesado do caminho principal, pra ele continuar livre pra atender o que realmente precisa de resposta imediata. A diferença é que, no frontend, esse "caminho principal" é a própria experiência visual do usuário, e qualquer travamento nela é sentido na hora, sem nenhuma margem de tolerância.

Direto na sua
caixa de entrada.

Um aviso por e-mail sempre que eu publicar um post novo. Sem spam, sem newsletter chata, só isso.