Web Workers em 2026: OffscreenCanvas, Comlink e SharedArrayBuffer na Prática
Guia prático de Web Workers em 2026: como usar OffscreenCanvas, Comlink e SharedArrayBuffer para tirar trabalho pesado da main thread e melhorar o INP em produção.
Um Web Worker é uma thread JavaScript separada que roda em paralelo à main thread do navegador, permitindo executar código pesado (parsing de JSON grande, criptografia, decodificação de imagens, cálculos numéricos) sem bloquear renderização nem interações. Em 2026, com o INP como Core Web Vital oficial, mover trabalho para workers deixou de ser luxo de app pesado e virou ferramenta de rotina. Qualquer tarefa que passa de 50 ms na main thread já é candidata a virar mensagem entre threads.
Web Workers rodam em uma thread própria com event loop isolado. Não têm acesso a document, window ou DOM, mas têm fetch, IndexedDB, WebSocket e crypto.
Use Transferable Objects (ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas) para transferir dados sem cópia. Isso reduz a latência de postMessage de dezenas de ms para microssegundos.
OffscreenCanvas permite renderizar Canvas 2D, WebGL e WebGPU dentro de um worker, eliminando jank em dashboards com muitos gráficos.
Comlink transforma a API assíncrona baseada em mensagens em chamadas de método com await, escondendo a plumbing sem custo de runtime relevante.
SharedArrayBuffer mais Atomics viabilizam memória compartilhada real entre threads, mas exigem cabeçalhos COOP/COEP e isolamento cross-origin.
Meça o impacto real com a Long Tasks API, o painel Performance do Chrome e a métrica INP do RUM. Não confie em microbenchmarks isolados.
O que é um Web Worker e como funciona em 2026
Um Web Worker é uma thread do sistema operacional criada pelo navegador para rodar um script JavaScript isolado da main thread. Cada worker tem seu próprio event loop, sua própria heap V8 (ou equivalente em Firefox/Safari) e não compartilha estado por padrão com o thread principal. A comunicação acontece por passagem de mensagens via postMessage(), e o payload passa por structured clone, a menos que você marque partes dele como transferíveis, o que evita cópia.
Na prática, o que essa arquitetura permite é o seguinte: quando o usuário clica em um botão e você precisa parsear 800 KB de JSON, aplicar um filtro em uma imagem ou rodar um argon2 para hash de senha, você não paga esse custo em cima da renderização. A main thread continua livre para responder a input, animar frames e atualizar o DOM. Em 2026, com o Chrome medindo INP a partir do maior atraso entre input e próxima pintura, essa separação passou a ter impacto direto em Core Web Vitals, não só em "sensação de fluidez".
Uma observação importante sobre o modelo: workers têm acesso a fetch, IndexedDB, WebSocket, WebAssembly, crypto.subtle, URL, Blob, ReadableStream e à maioria das APIs de rede e storage. O que eles NÃO têm é document, window, localStorage ou qualquer forma direta de tocar no DOM. Isso obriga a separar lógica de renderização de lógica de dados, o que é bom em geral, mas força refatorações em código legado que misturava as duas.
Quando (e quando não) usar Web Workers
A pergunta que sempre me fazem é: "vale a pena? o postMessage não é caro?". A resposta honesta depende do tamanho da tarefa. Cada postMessage gasta entre 0,05 ms e 5 ms dependendo do payload (structured clone escala com o tamanho do objeto). Se a tarefa toma 3 ms, você provavelmente perde tempo enviando ela para um worker. Se toma 30 ms ou mais, ganha em qualquer cenário. A regra de bolso que uso: se a função aparece consistentemente como Long Task (>50 ms) no painel Performance, ela é candidata a virar worker.
Casos onde Web Workers pagam o preço facilmente:
Parsing de JSON, CSV, XML ou Protobuf acima de ~200 KB.
Processamento de imagem/vídeo: filtros, resize, decodificação de HEIC, geração de thumbnails.
Criptografia: hashing lento (Argon2, bcrypt), assinatura, verificação de JWTs em lote.
Machine Learning no browser (TensorFlow.js, ONNX Runtime Web, Transformers.js). Praticamente obrigatório rodar em worker.
Diff de árvores grandes, layout de grafos, cálculos geoespaciais, físicas 2D/3D.
Compressão/descompressão (Brotli WASM, gzip via DecompressionStream) de arquivos grandes.
Casos onde não vale:
Trabalho que já é async e IO-bound (só fetch mais um JSON.parse pequeno). O custo de setup do worker excede o ganho.
Manipulação direta de DOM. Worker não pode tocar no DOM, e passar HTML strings de volta é desperdício.
Cálculos triviais (<5 ms). O overhead de postMessage dilui qualquer vantagem.
Como criar um Web Worker moderno com módulos ES
Em 2026, todo navegador evergreen suporta module workers, ou seja, workers que carregam ES modules com import/export nativo. Isso mata o padrão antigo de importScripts() e integra o worker ao seu bundler favorito. O snippet abaixo mostra o padrão que eu uso em código novo.
// main.js: código na main thread
const worker = new Worker(
new URL('./heavy.worker.js', import.meta.url),
{ type: 'module', name: 'json-parser' }
);
worker.addEventListener('message', (event) => {
console.log('resultado do worker:', event.data);
});
worker.addEventListener('error', (event) => {
console.error('erro no worker:', event.message);
});
// dispara o trabalho
worker.postMessage({ cmd: 'parse', payload: bigJsonString });
// heavy.worker.js: código dentro do worker
import { parseComplex } from './lib/parser.js';
self.addEventListener('message', async (event) => {
const { cmd, payload } = event.data;
if (cmd === 'parse') {
const result = parseComplex(payload);
self.postMessage(result);
}
});
O padrão new URL('./file.js', import.meta.url) é o que Vite, webpack 5, Rollup, Parcel e esbuild reconhecem para fazer code splitting automático do worker. O bundler emite um chunk separado e reescreve a URL para o hash final. Não use string literal em new Worker('./worker.js'): o bundler não consegue rastrear a dependência e o arquivo some do build de produção.
O parâmetro name é opcional, mas altamente recomendado. Ele aparece no DevTools em Sources → Threads e nas gravações de Performance, tornando bem mais fácil identificar qual worker está fazendo o quê quando você tem meia dúzia deles rodando ao mesmo tempo.
Transferable Objects vs Structured Clone: transferindo dados sem cópia
Structured clone é o algoritmo padrão que o navegador aplica quando você faz postMessage(data). Ele serializa o objeto (com suporte a Map, Set, Date, RegExp, TypedArrays, etc.) e reconstrói do outro lado. Para um objeto de 10 MB, essa cópia pode tomar de 20 a 80 ms, o que derrota completamente o propósito de usar worker se você faz isso em cada evento.
A solução é Transferable Objects: você passa a "posse" da memória para o worker sem copiar. O ArrayBuffer original fica detached na main thread (não pode mais ser lido) e passa a existir só no worker. É zero-copy real. Os tipos transferíveis em 2026 são: ArrayBuffer, MessagePort, ReadableStream, WritableStream, TransformStream, ImageBitmap, OffscreenCanvas, RTCDataChannel e MIDIAccess.
// main.js: transfere um ArrayBuffer de 50 MB sem custo
const buffer = new ArrayBuffer(50 * 1024 * 1024);
// preenche buffer...
// segundo argumento é a lista de transferíveis
worker.postMessage({ cmd: 'process', buffer }, [buffer]);
console.log(buffer.byteLength); // 0, foi detached!
// worker.js
self.addEventListener('message', (event) => {
const { buffer } = event.data;
const view = new Uint8Array(buffer);
// processa in-place, sem cópia
for (let i = 0; i < view.length; i++) view[i] ^= 0xff;
// devolve com transfer também
self.postMessage({ done: true, buffer }, [buffer]);
});
Meça a diferença: em um MacBook M2, transferir 10 MB via structured clone toma ~14 ms; via Transferable, menos de 0,2 ms. Em Android mid-range, a diferença é ainda maior (proporção parecida, valores absolutos ~3x). Eu já vi um app cair de 220 ms de INP para 40 ms só trocando o postMessage por transfer numa aba de importação de CSV. A regra é simples: se você está passando binário, use Transferable. Sempre.
OffscreenCanvas: renderização gráfica fora da main thread
Antes de 2018, canvas era prisioneiro da main thread. Se você tinha um dashboard com 20 gráficos <canvas> redesenhando a cada segundo, cada frame competia com scroll, hover e input, e o INP ia para o beleléu. OffscreenCanvas resolve isso: você cria um canvas no DOM, chama canvas.transferControlToOffscreen() e envia o objeto resultante para um worker, que passa a fazer todo o drawImage, getContext('webgl2'), ou até getContext('webgpu') longe da main thread.
// main.js
const canvas = document.querySelector('#chart');
const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker(
new URL('./chart.worker.js', import.meta.url),
{ type: 'module' }
);
worker.postMessage({ canvas: offscreen }, [offscreen]);
// A partir daqui, tocar em canvas na main thread lança InvalidStateError.
// chart.worker.js
let ctx;
self.addEventListener('message', (event) => {
if (event.data.canvas) {
ctx = event.data.canvas.getContext('2d');
requestAnimationFrame(render);
}
});
function render(time) {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
// desenha 10.000 barras sem bloquear scroll
drawChart(ctx, time);
requestAnimationFrame(render);
}
A adoção em 2026 é praticamente universal: Chrome desde 69, Firefox desde 105, Safari desde 16.4. Bibliotecas de gráfico modernas (Chart.js 5, ECharts 6, D3 v8 via helpers) já expõem opção para render em worker. Se você mantém uma UI com muitos canvas ou animações WebGL, migrar para OffscreenCanvas geralmente derruba o INP p75 em 30 a 60% em desktop e em 50 a 80% em mobile.
Comlink: RPC ergonômico com Web Workers
A API de postMessage mais listeners fica insuportável rápido. Você acaba montando um sistema de cmd: 'x'/cmd: 'y' com IDs de correlação, timeouts, tratamento de erro e serialização manual. É exatamente o problema que o Comlink, mantido pelo Google Chrome Labs, resolve. Ele usa Proxies do ES6 para transformar chamadas de método no worker em postMessage transparente com await na main thread.
// api.worker.js: API que quer expor
import * as Comlink from 'comlink';
const api = {
async parseCSV(text) {
// parsing pesado aqui
return rows;
},
async blurImage(bitmap, radius) {
// filtro gaussiano
return outputBitmap;
},
counter: 0,
};
Comlink.expose(api);
// main.js: consumo
import * as Comlink from 'comlink';
const worker = new Worker(
new URL('./api.worker.js', import.meta.url),
{ type: 'module' }
);
const api = Comlink.wrap(worker);
// Chamada parece síncrona, mas roda em worker
const rows = await api.parseCSV(csvText);
console.log(rows.length);
// Até propriedades funcionam
console.log(await api.counter);
O overhead do Comlink em cima de postMessage cru é minúsculo, menos de 0,3 ms por chamada em CPUs modernas, e ele passa Transferable transparentemente se você usar Comlink.transfer(payload, [payload.buffer]). Para 90% dos casos, essa é a abstração certa. Você mantém o código do worker escrito como classe/objeto normal e só publica o que quer expor. No último projeto que tirei do papel (um editor colaborativo com parsing de markdown em worker), Comlink cortou tipo 200 linhas de código de dispatch manual.
SharedArrayBuffer, Atomics e o requisito COOP/COEP
SharedArrayBuffer (SAB) é diferente de tudo acima. Ele é o único mecanismo em JS que permite memória de verdade compartilhada entre threads, com acesso concorrente via Atomics. Isso viabiliza padrões como fila lock-free entre worker e main, produtor/consumidor com Atomics.wait/Atomics.notify, e libs C++/Rust portadas para WebAssembly que dependem de pthreads.
Depois do Spectre/Meltdown, navegadores exigem cross-origin isolation para expor SAB. Você precisa servir sua página com dois cabeçalhos HTTP:
Sem eles, self.crossOriginIsolated é false e o construtor new SharedArrayBuffer(...) lança erro. O COEP require-corp impacta iframes e recursos de outras origens: todo <img>, <script> ou <iframe> cross-origin precisa enviar Cross-Origin-Resource-Policy: cross-origin ou vai ser bloqueado. Se seu site incorpora widgets de terceiros ou analytics, avalie caso a caso. Mudar para COEP credentialless (suportado desde Chrome 96) alivia isso.
// Fila ring buffer minimalista com SAB
const SIZE = 1024;
const sab = new SharedArrayBuffer(SIZE * 4 + 8);
const meta = new Int32Array(sab, 0, 2); // [head, tail]
const data = new Int32Array(sab, 8); // conteúdo
// Produtor (worker)
function push(value) {
const tail = Atomics.load(meta, 1);
data[tail % SIZE] = value;
Atomics.store(meta, 1, tail + 1);
Atomics.notify(meta, 1);
}
// Consumidor (main)
function pop() {
const head = Atomics.load(meta, 0);
const tail = Atomics.load(meta, 1);
if (head === tail) return null;
const value = data[head % SIZE];
Atomics.store(meta, 0, head + 1);
return value;
}
Para cenários típicos de dashboard, SAB é overkill. Use-o quando: você porta código nativo (FFmpeg WASM, SQLite WASM, engines de física), quando precisa de streaming em tempo real com latência sub-milissegundo, ou quando o custo de copiar dados via postMessage não cabe no seu budget. Detalhes das regras de isolamento estão na documentação oficial do Chrome sobre SharedArrayBuffer.
Web Worker vs Service Worker vs Shared Worker
A confusão entre os três tipos é constante. Os nomes são parecidos, mas os papéis são radicalmente diferentes:
Característica
Web Worker (dedicated)
Shared Worker
Service Worker
Escopo
1 documento
múltiplas abas mesma origem
toda a origem
Ciclo de vida
enquanto a página existe
enquanto alguma aba usar
independente do documento
Uso principal
trabalho pesado de CPU
estado compartilhado entre abas
proxy de rede, cache, push, offline
Acessa DOM?
não
não
não
Intercepta fetch?
não
não
sim (via fetch event)
Suporte Safari
total
desde 16.0
total
Bom para performance de CPU?
sim
sim (com ressalvas)
não (intended para rede/cache)
Uma armadilha comum é tentar usar Service Worker para "trabalho em background" no sentido de CPU. Não é isso que ele faz. Service Worker é um proxy de rede que fica entre a página e o servidor; ele até pode rodar código, mas o browser pode desligá-lo a qualquer momento (é event-driven). Para computação real, use Web Worker (ou Shared Worker se precisa de estado entre abas).
Como medir o impacto em INP e Long Tasks
Não confie em console.time em microbenchmarks. Meça o efeito real em usuários. Use três ferramentas em conjunto.
1. Long Tasks API para detectar tarefas >50 ms na main thread:
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log('Long task:', entry.duration.toFixed(1), 'ms',
entry.attribution?.[0]?.containerType);
}
});
observer.observe({ type: 'longtask', buffered: true });
Antes de migrar para worker, colete a distribuição. Depois, colete de novo. Você quer ver a cauda P95 encolher e o número de long tasks por sessão cair.
2. Painel Performance do Chrome: grave uma interação típica. A trilha "Main" mostra o thread principal; workers aparecem em trilhas próprias abaixo. Você deve ver: main quase vazia durante a interação, o worker fazendo o trabalho, e o resultado chegando de volta em um postMessage curto.
3. RUM com web-vitals.js: colete INP no P75 em produção. Segmente por tipo de dispositivo. O ganho real de mover código para worker aparece dramaticamente em Android mid/low-end, e muitas vezes fica discreto em desktop. Se você não segmentar, corre o risco de concluir que "não mudou nada" só porque a média está dominada por desktops. Aconteceu comigo num app de fintech em 2024, e só peguei o padrão depois de dividir por deviceMemory.
Worker pools e paralelismo em produção
Um worker só não usa mais que um core. Se você tem trabalho embaraçosamente paralelo (processar 200 imagens, cada uma independente), quer um pool: N workers, uma fila de tarefas, distribuição round-robin ou por menor carga. Como regra, N = navigator.hardwareConcurrency - 1 (deixa um core para main mais system). Em dispositivos mobile, cap em 4.
Para paralelismo mais elaborado (dependências entre tarefas, prioridades, cancelamento), considere bibliotecas prontas. A documentação da MDN sobre Web Workers lista várias, e o próprio Comlink pode ser combinado com um pool trivialmente. E lembre: se o seu bottleneck é I/O (fetch, IndexedDB), pool não ajuda; você só quer Promise.all na main thread mesmo.
Honestamente, se ainda restar main thread ocupada depois do pool, olhe também para a família Partytown para isolar scripts de terceiros. Ela usa a mesma primitiva de Web Workers para tirar tags de analytics e chat do caminho crítico. Combinado com code splitting agressivo do bundle principal, você fica com uma main thread magra o suficiente para INP ficar consistentemente abaixo de 200 ms mesmo em Android de entrada.
Perguntas frequentes
Web Workers têm acesso ao DOM?
Não. Workers não têm acesso a document, window ou qualquer API de manipulação de DOM. Eles têm acesso a fetch, IndexedDB, crypto, WebSocket, WebAssembly e à maioria das APIs de rede/storage. Para atualizar o DOM, o worker envia uma mensagem para a main thread e ela aplica a mudança.
Qual é a diferença entre Web Worker e Service Worker?
Web Worker é uma thread para trabalho de CPU (parsing, cripto, ML) escopada a um único documento. Service Worker é um proxy de rede que fica entre a página e o servidor, permitindo cache offline, push notifications e interceptação de fetch. Um Web Worker roda enquanto a página existe; um Service Worker vive independente da aba e o browser pode desativá-lo a qualquer momento.
Quando o overhead de postMessage não compensa usar um Web Worker?
Se a função executa em menos de ~5 ms na main thread, o custo de setup do worker mais o serialize/deserialize provavelmente supera o ganho. Meça com a Long Tasks API: tarefas <50 ms consistentes raramente precisam sair da main. Para tarefas >50 ms recorrentes ou >200 ms pontuais, o worker sempre paga.
Preciso servir COOP/COEP para usar Web Workers?
Não para Web Workers básicos. Você só precisa dos cabeçalhos Cross-Origin-Opener-Policy: same-origin e Cross-Origin-Embedder-Policy: require-corp se for usar SharedArrayBuffer ou APIs que exigem crossOriginIsolated como performance.measureUserAgentSpecificMemory() e certas features do WebGPU. Um worker "normal" com postMessage e Transferable funciona sem esses cabeçalhos.
Como saber se meu site vai ganhar performance movendo código para worker?
Faça três medições. Primeiro, use o painel Performance do Chrome durante a interação problemática e olhe o gráfico de main thread: se há barras vermelhas de Long Task, é candidato. Segundo, colete Long Tasks API em RUM e veja se essa função aparece na cauda P95. Terceiro, após migrar, compare INP P75 no CrUX/RUM segmentado por tipo de dispositivo (o ganho aparece principalmente em Android mid/low-end).
Descobre como reduzir drasticamente o teu bundle JavaScript com code splitting, tree shaking e dynamic imports. Inclui exemplos de código prontos a usar, ferramentas de análise e dicas de performance budgets para 2026.