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 e a experiência do usuário do app.

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 uma linha de execução por guia do navegador (com algumas exceções) para renderizar nossos sites e executar nosso JavaScript.

Como resultado, a linha de execução principal ficou sobrecarregada. À medida que os apps da Web se tornam mais complexos, a linha de execução principal se torna um gargalo significativo para o desempenho. Para piorar, o tempo necessário para executar o código na linha de execução principal para um determinado usuário é quase totalmente 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 de um conjunto cada vez mais diversificado de dispositivos, desde telefones básicos hiper-restritos até máquinas de última geração de alta potência e alta taxa de atualização.

Se quisermos que apps da Web sofisticados atendam de forma confiável às diretrizes de desempenho, como as Core Web Vitals, que se baseiam em dados empíricos sobre a percepção e a psicologia humanas, 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 workers da Web fornecem uma espécie de escape da linha de execução principal, permitindo que os desenvolvedores criem linhas de execução separadas para lidar com o trabalho fora dela. Embora o escopo dos workers da Web seja limitado e não ofereça acesso direto ao DOM, eles podem ser muito úteis se houver um trabalho considerável que, de outra forma, sobrecarregaria a linha de execução principal.

No que diz respeito às Core Web Vitals, executar o trabalho fora da linha de execução principal pode ser benéfico. Em particular, descarregar o trabalho da linha de execução principal para workers da Web pode reduzir a contenção da linha de execução principal, o que pode melhorar a métrica de responsividade da 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 pode beneficiar a Largest Contentful Paint (LCP) ao reduzir tarefas longas. A renderização de um elemento LCP requer tempo da linha de execução principal, seja para renderizar texto ou imagens, que são elementos LCP frequentes e comuns. Ao reduzir o trabalho da linha de execução principal em geral, você garante que o elemento LCP da página tenha menos probabilidade de ser bloqueado por um trabalho caro que um worker da Web poderia processar.

Linhas de execução com workers da Web

Outras plataformas geralmente oferecem suporte ao trabalho paralelo, 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 de ambas as linhas de execução, e o acesso a esses recursos compartilhados pode ser sincronizado com mutexes e semáforos para evitar disputas.

No JavaScript, podemos ter uma funcionalidade parecida com os workers da Web, que existem desde 2007 e são compatíveis com todos os principais navegadores desde 2012. Os workers da Web 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 para o 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 eventos 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);
});

É verdade que essa abordagem é um pouco limitada. Historicamente, os workers da Web têm sido usados principalmente para mover uma única parte de trabalho pesado para fora da linha de execução principal. Tentar lidar com várias operações com um único worker da Web se torna complicado rapidamente: é preciso codificar não apenas os parâmetros, mas também a operação na mensagem, e é necessário fazer a contabilidade para corresponder as respostas às solicitações. Essa complexidade é provavelmente o motivo pelo qual os workers da Web não foram adotados de forma mais ampla.

Mas 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. E, felizmente, existe uma biblioteca que faz exatamente isso.

A Comlink é uma biblioteca que permite usar workers da Web sem precisar pensar nos detalhes de postMessage. Com o Comlink, é possível compartilhar variáveis entre workers da Web e a linha de execução principal, quase como outras linguagens de programação que oferecem suporte a linhas de execução.

Para configurar o Comlink, importe-o em um worker da Web 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 acesse as 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 que cada função retorna uma promessa para um valor, em vez do valor em si.

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

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 pequeno trecho de código movido para um worker libera mais espaço na linha de execução principal para o que precisa estar lá, como atualizar a interface do usuário.

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

No entanto, se mudarmos para um modelo em que as preocupações com a interface são separadas de outras, como o gerenciamento de estado, os workers da Web podem ser bastante úteis, mesmo com apps 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 um clone do Campo Minado que atende aos requisitos de App Web Progressivo, incluindo o funcionamento off-line e uma experiência do usuário envolvente. Infelizmente, as primeiras versões do jogo tinham um desempenho ruim em dispositivos limitados, como telefones básicos, 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:

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

A OMT teve efeitos interessantes no desempenho do PROXX em telefones básicos. Na versão que não usa a OMT, a IU 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 poder fazer outra coisa.

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

No entanto, na versão OMT, o jogo leva doze segundos para concluir uma atualização da interface. Embora isso pareça uma perda de desempenho, na verdade, leva a um aumento no feedback para o usuário. A lentidão ocorre porque o app está enviando mais frames do que a versão que não é OMT, que não está enviando nenhum frame. Assim, o usuário sabe que algo está acontecendo e pode continuar jogando enquanto a IU é atualizada, o que melhora consideravelmente a experiência.

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

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

Implicações de uma arquitetura de OMT

Como mostra o exemplo do PROXX, o OMT faz com que o app seja executado de forma 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.
  • A sobrecarga de comunicação extra entre o worker da Web e a linha de execução principal pode, às vezes, deixar as coisas um pouco mais lentas.

Considere as vantagens e desvantagens

Como a linha de execução principal fica livre para processar interações do usuário, como rolagem, enquanto o JavaScript está em execução, há menos frames perdidos, 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 um frame acontece em milissegundos, enquanto você tem centenas de milissegundos antes que um usuário perceba o tempo de espera.

Devido à imprevisibilidade do desempenho em vários dispositivos, o objetivo da arquitetura OMT é reduzir o risco, tornando o app mais robusto diante de condições de execução altamente variáveis, e não os benefícios de desempenho da paralelização. O aumento na resiliência e as melhorias na UX compensam qualquer pequena perda de velocidade.

Uma observação sobre as ferramentas

Os workers da Web ainda não são muito usados, então a maioria das ferramentas de módulo, como webpack e Rollup, não oferece suporte a eles por padrão. (Parcel, sim!) Felizmente, existem 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 limitados, que são a forma como a maioria dos usuários acessa a Web no mundo todo. A OMT oferece uma maneira promissora de aumentar o desempenho nesses dispositivos sem afetar negativamente os usuários de dispositivos sofisticados.

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

  • Ele 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 IU 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 do Lighthouse.

Os workers da Web não precisam ser assustadores. Ferramentas como o Comlink estão facilitando o trabalho dos workers e tornando-os uma opção viável para uma ampla variedade de aplicativos da Web.