Por Que Seu App React Parece Lento: Melhorando Performance com Web Workers
Por Que Seu App React Parece Lento: Melhorando Performance com Web Workers
Você conhece essa sensação. Está construindo uma funcionalidade que processa dados—talvez filtrando uma lista grande, parseando um arquivo CSV, ou executando um cálculo. Em desenvolvimento com 100 registros, é instantâneo. Em produção com 50.000 registros, a interface inteira trava por 3 segundos. Usuários clicam em botões. Nada acontece. Clicam de novo. Ainda nada. Aí tudo explode de uma vez.
Sua thread principal está morrendo, e seus usuários estão indo embora.
Esse é o segredo sujo do JavaScript: é single-threaded. Toda animação, todo handler de eventos, toda resposta de API, e todo cálculo complexo compete por tempo nessa única thread. Quando uma operação pesada bloqueia ela, sua linda interface de 60fps cai para 0fps. O navegador literalmente não consegue pintar frames nem responder a inputs.
A solução? Web Workers. Mova esse trabalho custoso para fora da thread principal completamente. Deixe a thread da UI fazer o que faz de melhor—renderizar e responder aos usuários—enquanto uma thread em background processa números em paralelo.
Mas aqui está o problema: a maioria dos tutoriais de Web Workers mostra um exemplo trivial de "soma dois números" e pronto. Quando você tenta usar Workers numa aplicação React real, bate em paredes:
- Como compartilho estruturas de dados complexas?
- E os tipos do TypeScript?
- Como trato erros corretamente?
- Posso usar pacotes npm em Workers?
- Qual é a arquitetura certa pro meu app?
Este guia responde tudo isso. Vamos nos aprofundar.
Entendendo o Problema da Thread Principal
Antes de consertar o problema, vamos entendê-lo. JavaScript roda numa única thread chamada "main thread". Essa thread gerencia:
- Execução de JavaScript (seu código)
- Atualizações do DOM (renderização)
- Processamento de inputs do usuário (cliques, digitação)
- Timers e animações (requestAnimationFrame, setTimeout)
- Callbacks de rede (respostas de fetch)
Quando você executa uma função que leva 500ms para completar, todo o resto espera. O navegador não consegue repintar. Não consegue processar cliques. Da perspectiva do usuário, a página está congelada.
// Isso bloqueia a thread principal por ~500ms function heavyComputation(data) { // Imagine processar 50.000 itens return data.map(item => { // Transformação complexa return expensiveOperation(item); }); } // Quando isso roda, a UI trava const result = heavyComputation(hugeDataset);
Você pode pensar "só usar async/await!" Mas isso não ajuda aqui:
// Ainda bloqueia! async só ajuda com I/O, não trabalho de CPU async function stillBlocking(data) { // Essa computação ainda roda na thread principal return data.map(item => expensiveOperation(item)); }
A palavra-chave async só ajuda quando você está esperando algo externo (rede, disco). Para operações intensivas de CPU, você precisa de paralelismo real. É aí que entram os Web Workers.
Web Workers 101: O Modelo Mental
Um Web Worker é uma thread JavaScript separada que roda em paralelo com sua thread principal. Tem seu próprio event loop, seu próprio scope global, e—criticamente—não consegue acessar o DOM.
┌─────────────────────────────────────────────────────────────┐
│ PROCESSO DO NAVEGADOR │
├─────────────────────────┬───────────────────────────────────┤
│ THREAD PRINCIPAL │ THREAD DO WORKER │
├─────────────────────────┼───────────────────────────────────┤
│ • Acesso ao DOM │ • Sem acesso ao DOM │
│ • Objeto window │ • Objeto self │
│ • Eventos do usuário │ • Cálculos pesados │
│ • Renderização │ • Processamento de dados │
│ • Estado do React │ • Tarefas em background │
├─────────────────────────┴───────────────────────────────────┤
│ postMessage() / onmessage │
│ (Comunicação via structured cloning) │
└─────────────────────────────────────────────────────────────┘
A comunicação entre threads acontece via postMessage() e onmessage. Os dados são copiados (não compartilhados) entre threads usando o "structured clone algorithm"—essencialmente uma cópia profunda que suporta a maioria dos tipos JavaScript.
Aqui está o Worker mais simples possível:
// worker.js self.onmessage = function(event) { const data = event.data; const result = heavyComputation(data); self.postMessage(result); }; function heavyComputation(data) { // Trabalho custoso aqui return data.map(item => item * 2); }
// main.js const worker = new Worker('worker.js'); worker.onmessage = function(event) { console.log('Resultado:', event.data); }; worker.postMessage([1, 2, 3, 4, 5]);
Simples o suficiente. Mas isso gera perguntas: Como uso isso no React? E o TypeScript? Como compartilho lógica complexa? Vamos construir uma solução real.
Configurando Web Workers em React Moderno
Se você está usando Vite, Next.js, ou Create React App, cada um tem suporte ligeiramente diferente para Workers. Vamos cobrir a abordagem moderna que funciona em todos.
Opção 1: Workers Inline com Blob URLs
Para casos simples, você pode criar Workers a partir de código inline:
// utils/createWorker.ts export function createWorkerFromFunction<T, R>( fn: (data: T) => R ): (data: T) => Promise<R> { const workerCode = ` self.onmessage = function(e) { const fn = ${fn.toString()}; const result = fn(e.data); self.postMessage(result); }; `; const blob = new Blob([workerCode], { type: 'application/javascript' }); const workerUrl = URL.createObjectURL(blob); const worker = new Worker(workerUrl); return (data: T): Promise<R> => { return new Promise((resolve, reject) => { worker.onmessage = (e) => resolve(e.data); worker.onerror = (e) => reject(e); worker.postMessage(data); }); }; }
Uso:
const processInWorker = createWorkerFromFunction((numbers: number[]) => { return numbers.map(n => n * n).reduce((a, b) => a + b, 0); }); // Agora isso roda num Worker! const sum = await processInWorker([1, 2, 3, 4, 5]);
Limitação: A função não pode importar outros módulos nem usar closures. Precisa ser autocontida.
Opção 2: Suporte Nativo de Workers no Vite
Vite tem excelente suporte para Workers com o sufixo ?worker:
// workers/dataProcessor.worker.ts export interface WorkerInput { data: number[]; operation: 'sum' | 'average' | 'max'; } export interface WorkerOutput { result: number; processingTime: number; } self.onmessage = (event: MessageEvent<WorkerInput>) => { const start = performance.now(); const { data, operation } = event.data; let result: number; switch (operation) { case 'sum': result = data.reduce((a, b) => a + b, 0); break; case 'average': result = data.reduce((a, b) => a + b, 0) / data.length; break; case 'max': result = Math.max(...data); break; } const output: WorkerOutput = { result, processingTime: performance.now() - start, }; self.postMessage(output); };
// hooks/useDataProcessor.ts import { useCallback, useRef, useEffect } from 'react'; import DataProcessorWorker from '../workers/dataProcessor.worker?worker'; import type { WorkerInput, WorkerOutput } from '../workers/dataProcessor.worker'; export function useDataProcessor() { const workerRef = useRef<Worker | null>(null); useEffect(() => { workerRef.current = new DataProcessorWorker(); return () => workerRef.current?.terminate(); }, []); const process = useCallback((input: WorkerInput): Promise<WorkerOutput> => { return new Promise((resolve, reject) => { if (!workerRef.current) { reject(new Error('Worker not initialized')); return; } workerRef.current.onmessage = (e) => resolve(e.data); workerRef.current.onerror = (e) => reject(e); workerRef.current.postMessage(input); }); }, []); return { process }; }
Opção 3: Comlink para APIs Mais Limpas
Comlink do Google Chrome Labs faz Workers parecerem funções async normais:
// workers/imageProcessor.worker.ts import * as Comlink from 'comlink'; const api = { async processImage(imageData: ImageData): Promise<ImageData> { // Processamento pesado de imagem const pixels = imageData.data; for (let i = 0; i < pixels.length; i += 4) { // Conversão para escala de cinza const avg = (pixels[i] + pixels[i + 1] + pixels[i + 2]) / 3; pixels[i] = avg; // R pixels[i + 1] = avg; // G pixels[i + 2] = avg; // B } return imageData; }, async analyzeData(data: number[]): Promise<{ mean: number; stdDev: number; median: number; }> { const sorted = [...data].sort((a, b) => a - b); const mean = data.reduce((a, b) => a + b, 0) / data.length; const squaredDiffs = data.map(x => Math.pow(x - mean, 2)); const variance = squaredDiffs.reduce((a, b) => a + b, 0) / data.length; return { mean, stdDev: Math.sqrt(variance), median: sorted[Math.floor(sorted.length / 2)], }; }, }; Comlink.expose(api); export type WorkerApi = typeof api;
// hooks/useImageProcessor.ts import { useEffect, useRef } from 'react'; import * as Comlink from 'comlink'; import type { WorkerApi } from '../workers/imageProcessor.worker'; export function useImageProcessor() { const workerRef = useRef<Comlink.Remote<WorkerApi> | null>(null); useEffect(() => { const worker = new Worker( new URL('../workers/imageProcessor.worker.ts', import.meta.url), { type: 'module' } ); workerRef.current = Comlink.wrap<WorkerApi>(worker); return () => worker.terminate(); }, []); return workerRef.current; } // Uso no componente function ImageEditor() { const processor = useImageProcessor(); const handleProcess = async () => { if (!processor) return; // Parece uma chamada async normal! const result = await processor.processImage(imageData); setProcessedImage(result); }; }
Comlink cuida de toda a encanação do postMessage e faz Workers parecerem naturais.
Exemplo do Mundo Real: Parser CSV Que Não Trava
Vamos construir algo prático. Imagine que você está construindo um app que importa arquivos CSV. Usuários sobem um CSV de 50MB com 500.000 linhas. Sem Workers, parsear isso trava a UI por 5-10 segundos.
// workers/csvParser.worker.ts import * as Comlink from 'comlink'; interface ParseOptions { delimiter?: string; hasHeader?: boolean; } interface ParseResult { headers: string[]; rows: string[][]; rowCount: number; parseTimeMs: number; } interface ProgressCallback { (progress: number): void; } const csvParser = { async parse( csvText: string, options: ParseOptions = {}, onProgress?: ProgressCallback ): Promise<ParseResult> { const start = performance.now(); const { delimiter = ',', hasHeader = true } = options; const lines = csvText.split('\n'); const totalLines = lines.length; const headers: string[] = []; const rows: string[][] = []; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (!line) continue; const values = parseCsvLine(line, delimiter); if (i === 0 && hasHeader) { headers.push(...values); } else { rows.push(values); } // Reportar progresso a cada 1000 linhas if (onProgress && i % 1000 === 0) { onProgress(Math.round((i / totalLines) * 100)); } } return { headers, rows, rowCount: rows.length, parseTimeMs: performance.now() - start, }; }, async filter( rows: string[][], columnIndex: number, predicate: string, onProgress?: ProgressCallback ): Promise<string[][]> { const result: string[][] = []; for (let i = 0; i < rows.length; i++) { const value = rows[i][columnIndex]; if (value?.toLowerCase().includes(predicate.toLowerCase())) { result.push(rows[i]); } if (onProgress && i % 1000 === 0) { onProgress(Math.round((i / rows.length) * 100)); } } return result; }, async aggregate( rows: string[][], groupByColumn: number, aggregateColumn: number ): Promise<Map<string, number>> { const groups = new Map<string, number[]>(); for (const row of rows) { const key = row[groupByColumn]; const value = parseFloat(row[aggregateColumn]); if (!isNaN(value)) { if (!groups.has(key)) { groups.set(key, []); } groups.get(key)!.push(value); } } const result = new Map<string, number>(); for (const [key, values] of groups) { result.set(key, values.reduce((a, b) => a + b, 0)); } return result; }, }; function parseCsvLine(line: string, delimiter: string): string[] { const result: string[] = []; let current = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const char = line[i]; if (char === '"') { inQuotes = !inQuotes; } else if (char === delimiter && !inQuotes) { result.push(current.trim()); current = ''; } else { current += char; } } result.push(current.trim()); return result; } Comlink.expose(csvParser); export type CsvParser = typeof csvParser;
// hooks/useCsvParser.ts import { useEffect, useRef, useState, useCallback } from 'react'; import * as Comlink from 'comlink'; import type { CsvParser } from '../workers/csvParser.worker'; export function useCsvParser() { const workerRef = useRef<Comlink.Remote<CsvParser> | null>(null); const [progress, setProgress] = useState(0); const [isProcessing, setIsProcessing] = useState(false); useEffect(() => { const worker = new Worker( new URL('../workers/csvParser.worker.ts', import.meta.url), { type: 'module' } ); workerRef.current = Comlink.wrap<CsvParser>(worker); return () => worker.terminate(); }, []); const parseFile = useCallback(async (file: File) => { if (!workerRef.current) throw new Error('Worker not ready'); setIsProcessing(true); setProgress(0); try { const text = await file.text(); const result = await workerRef.current.parse( text, { hasHeader: true }, Comlink.proxy((p: number) => setProgress(p)) ); return result; } finally { setIsProcessing(false); setProgress(100); } }, []); return { parseFile, progress, isProcessing, parser: workerRef.current, }; }
// components/CsvImporter.tsx import { useState } from 'react'; import { useCsvParser } from '../hooks/useCsvParser'; export function CsvImporter() { const { parseFile, progress, isProcessing } = useCsvParser(); const [result, setResult] = useState<ParseResult | null>(null); const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => { const file = e.target.files?.[0]; if (!file) return; const parsed = await parseFile(file); setResult(parsed); }; return ( <div> <input type="file" accept=".csv" onChange={handleFileChange} disabled={isProcessing} /> {isProcessing && ( <div className="progress-bar"> <div className="progress-fill" style={{ width: `${progress}%` }} /> <span>Processando... {progress}%</span> </div> )} {result && ( <div className="result"> <p>{result.rowCount.toLocaleString()} linhas parseadas</p> <p>Tempo: {result.parseTimeMs.toFixed(2)}ms</p> <table> <thead> <tr> {result.headers.map((h, i) => ( <th key={i}>{h}</th> ))} </tr> </thead> <tbody> {result.rows.slice(0, 100).map((row, i) => ( <tr key={i}> {row.map((cell, j) => ( <td key={j}>{cell}</td> ))} </tr> ))} </tbody> </table> </div> )} </div> ); }
Agora o parsing CSV acontece em background. A UI permanece responsiva. Usuários veem atualizações de progresso. Sem travamento.
Lidando com Dados Grandes: Transferable Objects
Copiar grandes quantidades de dados entre threads é lento. Para ArrayBuffer, MessagePort, e OffscreenCanvas, você pode usar transferable objects para mover dados sem copiar:
// Em vez de copiar, transfira a propriedade const buffer = new ArrayBuffer(1024 * 1024 * 100); // 100MB // Lento: copia o buffer inteiro worker.postMessage({ buffer }); // Rápido: transfere propriedade (buffer fica inutilizável na thread principal) worker.postMessage({ buffer }, [buffer]);
Para processamento de imagem, isso é enorme:
// workers/imageProcessor.worker.ts self.onmessage = async (e: MessageEvent) => { const { imageData } = e.data; // Processar os dados da imagem const processed = applyFilter(imageData); // Transferir de volta em vez de copiar const buffer = processed.data.buffer; self.postMessage({ imageData: processed }, [buffer]); };
// thread principal const offscreen = canvas.transferControlToOffscreen(); worker.postMessage({ canvas: offscreen }, [offscreen]);
Pool de Workers para Processamento Paralelo
Um Worker é bom. Múltiplos Workers processando em paralelo é melhor. Aqui está um pool simples de Workers:
// utils/WorkerPool.ts export class WorkerPool<TInput, TOutput> { private workers: Worker[] = []; private queue: Array<{ data: TInput; resolve: (result: TOutput) => void; reject: (error: Error) => void; }> = []; private activeWorkers = new Set<Worker>(); constructor( private createWorker: () => Worker, private poolSize: number = navigator.hardwareConcurrency || 4 ) { for (let i = 0; i < poolSize; i++) { this.workers.push(this.createWorker()); } } async execute(data: TInput): Promise<TOutput> { return new Promise((resolve, reject) => { const availableWorker = this.workers.find( w => !this.activeWorkers.has(w) ); if (availableWorker) { this.runTask(availableWorker, data, resolve, reject); } else { this.queue.push({ data, resolve, reject }); } }); } private runTask( worker: Worker, data: TInput, resolve: (result: TOutput) => void, reject: (error: Error) => void ) { this.activeWorkers.add(worker); worker.onmessage = (e) => { resolve(e.data); this.activeWorkers.delete(worker); this.processQueue(); }; worker.onerror = (e) => { reject(new Error(e.message)); this.activeWorkers.delete(worker); this.processQueue(); }; worker.postMessage(data); } private processQueue() { if (this.queue.length === 0) return; const availableWorker = this.workers.find( w => !this.activeWorkers.has(w) ); if (availableWorker) { const { data, resolve, reject } = this.queue.shift()!; this.runTask(availableWorker, data, resolve, reject); } } async executeAll(items: TInput[]): Promise<TOutput[]> { return Promise.all(items.map(item => this.execute(item))); } terminate() { this.workers.forEach(w => w.terminate()); this.workers = []; } }
Uso para processamento paralelo de imagens:
const pool = new WorkerPool( () => new Worker(new URL('./imageWorker.ts', import.meta.url)), 4 // 4 workers paralelos ); // Processar 100 imagens em paralelo através de 4 workers const results = await pool.executeAll(images);
Erros Comuns e Como Evitá-los
Erro 1: Usar Workers em Excesso para Tarefas Pequenas
Workers têm overhead. Criar um Worker, serializar dados, e deserializar resultados leva tempo. Para operações pequenas, esse overhead pode exceder o tempo de computação.
Regra geral: Só use Workers quando a operação levar >16ms (um frame a 60fps).
// ❌ Não faça isso - overhead excede o benefício worker.postMessage([1, 2, 3]); // Somar 3 números // ✅ Faça isso - justifica o overhead worker.postMessage(bigArray); // Processar 100.000 itens
Erro 2: Esquecer de Terminar Workers
Workers consomem memória. Se você cria novos Workers sem terminar os antigos, terá memory leaks:
// ❌ Memory leak function processData(data) { const worker = new Worker('worker.js'); worker.postMessage(data); // Worker nunca terminado! } // ✅ Limpe corretamente function processData(data) { const worker = new Worker('worker.js'); worker.onmessage = (e) => { console.log(e.data); worker.terminate(); // Limpa quando terminar }; worker.postMessage(data); }
No React, sempre termine na limpeza:
useEffect(() => { const worker = new Worker('worker.js'); workerRef.current = worker; return () => { worker.terminate(); // ✅ Limpeza ao desmontar }; }, []);
Erro 3: Não Tratar Erros
Erros do Worker não propagam automaticamente para seus handlers de erro da thread principal:
// ❌ Erros ignorados silenciosamente worker.postMessage(data); // ✅ Trate erros explicitamente worker.onerror = (error) => { console.error('Erro do Worker:', error.message); // Trate o erro apropriadamente }; worker.onmessageerror = (error) => { console.error('Erro de mensagem:', error); };
Erro 4: Bloquear a Thread do Worker
Só porque você moveu trabalho pra um Worker não significa que não pode bloqueá-lo. Se seu Worker faz 5 segundos de trabalho síncrono, não consegue responder a outras mensagens durante esse tempo.
// ❌ Tarefa síncrona longa bloqueia todas as mensagens self.onmessage = (e) => { const result = process5MillionItems(e.data); // Bloqueia por 5 segundos self.postMessage(result); }; // ✅ Divida o trabalho e ceda periodicamente self.onmessage = async (e) => { const items = e.data; const results = []; const chunkSize = 10000; for (let i = 0; i < items.length; i += chunkSize) { const chunk = items.slice(i, i + chunkSize); results.push(...processChunk(chunk)); // Cede para permitir que outras mensagens sejam processadas await new Promise(resolve => setTimeout(resolve, 0)); // Reportar progresso self.postMessage({ type: 'progress', value: i / items.length }); } self.postMessage({ type: 'complete', results }); };
Quando NÃO Usar Web Workers
Workers nem sempre são a resposta. Evite-os quando:
-
A operação é I/O bound, não CPU bound - Fetching de dados, queries de banco de dados. Use async/await.
-
Você precisa de acesso ao DOM - Workers não podem tocar o DOM. Se precisar atualizar a UI durante o processamento, deve enviar mensagens para a thread principal.
-
O custo de transferência de dados excede a economia de processamento - Serializar 1GB de dados para economizar 100ms de processamento não vale a pena.
-
A operação já é rápida - Se completa em <16ms, o overhead não vale a pena.
-
Está mirando navegadores antigos sem fallback - Verifique seus requisitos de suporte a navegadores.
Comparação de Performance: Antes e Depois
Vamos medir o impacto com um exemplo real—ordenar 1 milhão de números:
// Sem Worker function sortData() { const start = performance.now(); const sorted = data.sort((a, b) => a - b); console.log(`Thread principal: ${performance.now() - start}ms`); console.log(`UI travada durante esse tempo`); } // Com Worker async function sortDataWorker() { const start = performance.now(); const sorted = await workerSort(data); console.log(`Worker: ${performance.now() - start}ms`); console.log(`UI permaneceu responsiva`); }
Resultados numa máquina típica:
- Sem Worker: 1,200ms (UI travada)
- Com Worker: 1,250ms (UI responsiva)
O tempo total é ligeiramente mais longo com Workers devido ao overhead de serialização. Mas a experiência do usuário é dramaticamente melhor porque a UI nunca trava.
Conclusão: Tire a Dor da Thread Principal
Web Workers são uma das APIs de navegador mais subutilizadas. Quando usados corretamente, transformam aplicações que parecem lentas em apps que parecem instantâneas e responsivas.
Pontos principais:
-
A thread principal é preciosa - Reserve-a para renderização e interação do usuário.
-
Workers são para trabalho CPU-bound - Processamento de dados, parsing, cálculos, manipulação de imagens.
-
Use Comlink para APIs mais limpas - Faz Workers parecerem funções async normais.
-
Transfira, não copie - Use transferable objects para ArrayBuffers grandes.
-
Pool de Workers para paralelismo - Múltiplos workers podem processar dados em paralelo.
-
Meça antes de otimizar - Workers têm overhead. Só use quando o benefício exceder o custo.
-
Não esqueça a limpeza - Sempre termine Workers quando terminar.
Da próxima vez que seu app React gaguejar durante uma operação pesada, você saberá exatamente o que fazer. Mova esse trabalho pra um Worker, mantenha a thread principal livre, e dê aos seus usuários a experiência fluida que eles merecem.