Use web workers para executar JavaScript fora da thread principal do navegador

Uma arquitetura fora da linha de execução principal pode melhorar significativamente a confiabilidade do app e a experiência do usuário.

Nos últimos 20 anos, a Web evoluiu drasticamente, de documentos estáticos com alguns estilos e imagens para aplicativos complexos e dinâmicos. No entanto, uma coisa permaneceu praticamente inalterada: temos apenas um tópico por guia do navegador (com algumas exceções) para fazer o trabalho de renderização de nossos sites e execução de JavaScript.

Como resultado, a linha de execução principal ficou incrivelmente sobrecarregada. À medida que os apps da Web ficam mais complexos, a linha de execução principal se torna um gargalo significativo para o desempenho. Para piorar a situação, o tempo necessário para executar o código na linha de execução principal de um determinado usuário é quase completamente imprevisível, porque os recursos do dispositivo têm um efeito enorme no desempenho. Essa imprevisibilidade só vai aumentar à medida que os usuários acessarem a Web em um conjunto cada vez mais diversificado de dispositivos, desde feature phones com recursos limitados até máquinas de alto desempenho e taxa de atualização alta.

Para que apps da Web sofisticados atendam às diretrizes de desempenho de maneira confiável, como as Core Web Vitals, que são baseadas em dados empíricos sobre percepção e psicologia humana, precisamos de maneiras de executar nosso código fora da linha de execução principal (OMT, na sigla em inglês).

Por que usar workers da Web?

Por padrão, o JavaScript é uma linguagem de linha de execução única que executa tarefas na linha de execução principal. No entanto, os Web Workers oferecem uma espécie de saída de emergência da linha de execução principal, permitindo que os desenvolvedores criem linhas de execução separadas para processar o trabalho fora da linha de execução principal. Embora o escopo dos Web Workers seja limitado e não ofereça acesso direto ao DOM, eles podem ser extremamente benéficos se houver um trabalho considerável que precisa ser feito e que, de outra forma, sobrecarregaria a linha de execução principal.

No caso das Core Web Vitals, executar o trabalho fora da linha de execução principal pode ser benéfico. Em particular, o descarregamento do trabalho da linha de execução principal para os Web Workers pode reduzir a contenção da linha de execução principal, o que pode melhorar a métrica de responsividade Interaction to Next Paint (INP) de uma página. Quando a linha de execução principal tem menos trabalho para processar, ela pode responder mais rapidamente às interações do usuário.

Menos trabalho na linha de execução principal, especialmente durante a inicialização, também traz um benefício potencial para a maior exibição de conteúdo (LCP), reduzindo tarefas longas. Renderizar um elemento da LCP requer o tempo da linha de execução principal, seja para renderizar textos ou imagens, que são elementos LCP frequentes e comuns, e reduzindo o trabalho geral da linha de execução principal, para garantir que o elemento LCP da sua página tenha menos probabilidade de ser bloqueado por trabalhos caros que um worker da Web poderia realizar.

Linhas de execução com web workers

Outras plataformas geralmente oferecem suporte a trabalhos paralelos, permitindo que você atribua uma função a uma linha de execução, que é executada em paralelo com o restante do programa. É possível acessar as mesmas variáveis em ambas as linhas de execução, e o acesso a esses recursos compartilhados pode ser sincronizado com mutexes e semáforos para evitar condições de corrida.

No JavaScript, podemos ter uma funcionalidade semelhante com os workers da Web, que existem desde 2007 e são compatíveis com todos os principais navegadores desde 2012. Os Web Workers são executados em paralelo com a linha de execução principal, mas, ao contrário da linha de execução do SO, eles não podem compartilhar variáveis.

Para criar um worker da Web, transmita um arquivo ao construtor do worker, que começa a executar esse arquivo em uma linha de execução separada:

const worker = new Worker("./worker.js");

Comunique-se com o worker da Web enviando mensagens usando a API postMessage. Transmita o valor da mensagem como um parâmetro na chamada postMessage e adicione um listener de evento de mensagem ao worker:

main.js

const worker = new Worker('./worker.js');
worker.postMessage([40, 2]);

worker.js

addEventListener('message', event => {
  const [a, b] = event.data;

  // Do stuff with the message
  // ...
});

Para enviar uma mensagem de volta à linha de execução principal, use a mesma API postMessage no worker da Web e configure um listener de eventos na linha de execução principal:

main.js

const worker = new Worker('./worker.js');

worker.postMessage([40, 2]);
worker.addEventListener('message', event => {
  console.log(event.data);
});

worker.js

addEventListener('message', event => {
  const [a, b] = event.data;

  // Do stuff with the message
  postMessage(a + b);
});

Essa abordagem é um pouco limitada. Historicamente, os Web Workers eram usados principalmente para mover uma única parte de trabalho pesado da linha de execução principal. Tentar processar várias operações com um único worker da Web se torna inviável rapidamente: é preciso codificar não apenas os parâmetros, mas também a operação na mensagem, além de fazer a contabilidade para corresponder as respostas às solicitações. Essa complexidade provavelmente é o motivo pelo qual os web workers não foram adotados de forma mais ampla.

No entanto, se pudéssemos remover parte da dificuldade de comunicação entre a linha de execução principal e os workers da Web, esse modelo seria uma ótima opção para muitos casos de uso. Felizmente, há uma biblioteca que faz exatamente isso.

O comlink (em inglês) é uma biblioteca cujo objetivo é permitir que você use web workers sem precisar pensar nos detalhes do postMessage. O Comlink permite que você compartilhe variáveis entre web workers e a linha de execução principal de forma semelhante a outras linguagens de programação compatíveis com linhas de execução.

Para configurar o Comlink, importe-o em um web worker e defina um conjunto de funções para expor à linha de execução principal. Em seguida, importe o Comlink na linha de execução principal, envolva o worker e tenha acesso às funções expostas:

worker.js

import {expose} from 'comlink';

const api = {
  someMethod() {
    // ...
  }
}

expose(api);

main.js

import {wrap} from 'comlink';

const worker = new Worker('./worker.js');
const api = wrap(worker);

A variável api na linha de execução principal se comporta da mesma forma que a do worker da Web, exceto pelo fato de que cada função retorna uma promessa para um valor em vez do valor em si.

Qual código você deve mover para um web worker?

Os workers da Web não têm acesso ao DOM e a muitas APIs, como WebUSB, WebRTC ou Web Audio. Portanto, não é possível colocar partes do app que dependem desse acesso em um worker. Ainda assim, cada pequena parte do código movida para um worker compra mais espaço na linha de execução principal para coisas que precisam estar lá, como atualizar a interface do usuário.

Um problema para os desenvolvedores da Web é que a maioria dos apps da Web depende de uma estrutura de interface, como Vue ou React, para orquestrar tudo no app. Tudo é um componente da estrutura e, portanto, está inerentemente vinculado ao DOM. Isso dificulta a migração para uma arquitetura OMT.

No entanto, se mudarmos para um modelo em que as questões de IU são separadas de outras questões, como gerenciamento de estado, os web workers podem ser bastante úteis até mesmo com aplicativos baseados em framework. Essa é exatamente a abordagem adotada com o PROXX.

PROXX: um estudo de caso de OMT

A equipe do Google Chrome desenvolveu o PROXX como uma versão do Minesweeper que atende aos requisitos de Progressive Web App, incluindo o funcionamento off-line e uma experiência do usuário envolvente. Infelizmente, as primeiras versões do jogo tiveram um desempenho ruim em dispositivos restritos, como feature phones, o que levou a equipe a perceber que a linha de execução principal era um gargalo.

A equipe decidiu usar workers da Web para separar o estado visual do jogo da lógica dele:

  • A linha de execução principal lida com a renderização de animações e transições.
  • Um worker da Web processa a lógica do jogo, que é puramente computacional.

O OMT teve efeitos interessantes no desempenho do telefone básico do PROXX. Na versão sem OMT, a interface fica congelada por seis segundos após a interação do usuário. Não há feedback, e o usuário precisa esperar os seis segundos completos antes de fazer outra coisa.

Tempo de resposta da interface na versão não OMT do PROXX.

Na versão OMT, no entanto, o jogo leva doze segundos para concluir uma atualização da interface. Embora isso pareça uma perda de desempenho, na verdade, ele leva a um aumento do feedback para o usuário. A lentidão ocorre porque o app está enviando mais frames do que a versão sem OMT, que não envia frames. Portanto, o usuário sabe que algo está acontecendo e pode continuar jogando à medida que a interface é atualizada, tornando o jogo consideravelmente melhor.

Tempo de resposta da IU na versão OMT do PROXX.

Essa é uma troca consciente: oferecemos aos usuários de dispositivos limitados uma experiência que parece melhor sem penalizar os usuários de dispositivos de última geração.

Implicações de uma arquitetura de OMT

Como mostra o exemplo do PROXX, o OMT faz com que seu app seja executado de maneira confiável em uma variedade maior de dispositivos, mas não o torna mais rápido:

  • Você está apenas movendo o trabalho da linha de execução principal, não reduzindo o trabalho.
  • A sobrecarga de comunicação extra entre o worker da Web e a linha de execução principal pode, às vezes, tornar as coisas ligeiramente mais lentas.

Considere os prós e contras

Como a linha de execução principal está livre para processar interações do usuário, como rolagem, enquanto o JavaScript está em execução, há menos frames descartados, mesmo que o tempo de espera total seja um pouco maior. Fazer o usuário esperar um pouco é melhor do que descartar um frame, porque a margem de erro é menor para frames descartados: o descarte de frames acontece em milissegundos, enquanto o usuário tem centenas de milissegundos até perceber o tempo de espera.

Devido à imprevisibilidade do desempenho em todos os dispositivos, o objetivo da arquitetura de OMT é realmente reduzir o risco, tornar seu app mais robusto diante de condições de tempo de execução altamente variáveis, e não os benefícios de desempenho do carregamento em paralelo. O aumento na resiliência e as melhorias na experiência do usuário são mais do que pequenas compensações em termos de velocidade.

Observação sobre as ferramentas

Os workers da Web ainda não são populares, então a maioria das ferramentas de módulo, como webpack e Rollup, não oferece suporte a eles. Mas o Parcel sim. Felizmente, há plug-ins para fazer com que os workers da Web funcionem com o Webpack e o Rollup:

Resumo

Para garantir que nossos apps sejam os mais confiáveis e acessíveis possível, especialmente em um mercado cada vez mais globalizado, precisamos oferecer suporte a dispositivos restritos. Eles são a forma como a maioria dos usuários acessa a Web no mundo todo. O OMT é uma maneira promissora de aumentar a performance nesses dispositivos sem afetar negativamente os usuários de dispositivos de última geração.

Além disso, o OMT tem benefícios secundários:

  • Move os custos de execução do JavaScript para uma linha de execução separada.
  • Ele move os custos de análise, o que significa que a interface pode ser inicializada mais rapidamente. Isso pode reduzir a First Contentful Paint ou até mesmo o Time to Interactive, o que pode aumentar sua pontuação no Lighthouse.

Os web workers não precisam ser assustadores. Ferramentas como a Comlink estão tirando o trabalho dos trabalhadores e tornando-as uma opção viável para uma ampla gama de aplicativos da Web.