Back

Por Qué Tu App React Se Siente Lenta: Mejorando el Rendimiento con Web Workers

Por Qué Tu App React Se Siente Lenta: Mejorando el Rendimiento con Web Workers

Conoces esa sensación. Estás construyendo una funcionalidad que procesa datos—quizás filtrando una lista grande, parseando un archivo CSV, o ejecutando un cálculo. En desarrollo con 100 registros, es instantáneo. En producción con 50,000 registros, toda la interfaz se congela por 3 segundos. Los usuarios hacen clic en botones. Nada pasa. Hacen clic de nuevo. Sigue sin pasar nada. Luego todo explota de golpe.

Tu hilo principal está muriendo, y tus usuarios se están yendo.

Este es el secreto sucio de JavaScript: es single-threaded. Cada animación, cada manejador de eventos, cada respuesta de API, y cada cálculo complejo compite por tiempo en ese único hilo. Cuando una operación pesada lo bloquea, tu hermosa interfaz de 60fps cae a 0fps. El navegador literalmente no puede pintar frames ni responder a inputs.

¿La solución? Web Workers. Mueve ese trabajo costoso fuera del hilo principal por completo. Deja que el hilo de la UI haga lo que mejor sabe hacer—renderizar y responder a los usuarios—mientras un hilo en segundo plano procesa números en paralelo.

Pero aquí está el problema: la mayoría de tutoriales de Web Workers te muestran un ejemplo trivial de "suma dos números" y listo. Cuando intentas usar Workers en una aplicación React real, chocas con paredes:

  • ¿Cómo comparto estructuras de datos complejas?
  • ¿Qué pasa con los tipos de TypeScript?
  • ¿Cómo manejo errores correctamente?
  • ¿Puedo usar paquetes npm en Workers?
  • ¿Cuál es la arquitectura correcta para mi app?

Esta guía responde todo eso. Vamos a profundizar.

Entendiendo el Problema del Hilo Principal

Antes de arreglar el problema, entendámoslo. JavaScript corre en un único hilo llamado el "hilo principal" (main thread). Este hilo maneja:

  1. Ejecución de JavaScript (tu código)
  2. Actualizaciones del DOM (renderizado)
  3. Procesamiento de inputs del usuario (clics, escritura)
  4. Timers y animaciones (requestAnimationFrame, setTimeout)
  5. Callbacks de red (respuestas de fetch)

Cuando ejecutas una función que toma 500ms en completarse, todo lo demás espera. El navegador no puede repintar. No puede procesar clics. Desde la perspectiva del usuario, la página está congelada.

// Esto bloquea el hilo principal por ~500ms function heavyComputation(data) { // Imagina procesar 50,000 elementos return data.map(item => { // Transformación compleja return expensiveOperation(item); }); } // Cuando esto se ejecuta, la UI se congela const result = heavyComputation(hugeDataset);

Podrías pensar "¡solo usa async/await!" Pero eso no ayuda aquí:

// ¡Sigue bloqueando! async solo ayuda con I/O, no con trabajo de CPU async function stillBlocking(data) { // Esta computación sigue ejecutándose en el hilo principal return data.map(item => expensiveOperation(item)); }

La palabra clave async solo ayuda cuando estás esperando algo externo (red, disco). Para operaciones intensivas de CPU, necesitas paralelismo real. Ahí es donde entran los Web Workers.

Web Workers 101: El Modelo Mental

Un Web Worker es un hilo JavaScript separado que corre en paralelo con tu hilo principal. Tiene su propio event loop, su propio scope global, y—críticamente—no puede acceder al DOM.

┌─────────────────────────────────────────────────────────────┐
│                     PROCESO DEL NAVEGADOR                    │
├─────────────────────────┬───────────────────────────────────┤
│      HILO PRINCIPAL     │         HILO DEL WORKER           │
├─────────────────────────┼───────────────────────────────────┤
│  • Acceso al DOM        │  • Sin acceso al DOM              │
│  • Objeto window        │  • Objeto self                     │
│  • Eventos de usuario   │  • Cálculos pesados               │
│  • Renderizado          │  • Procesamiento de datos          │
│  • Estado de React      │  • Tareas en segundo plano         │
├─────────────────────────┴───────────────────────────────────┤
│              postMessage() / onmessage                       │
│         (Comunicación via structured cloning)                │
└─────────────────────────────────────────────────────────────┘

La comunicación entre hilos ocurre via postMessage() y onmessage. Los datos son copiados (no compartidos) entre hilos usando el "structured clone algorithm"—esencialmente una copia profunda que soporta la mayoría de tipos JavaScript.

Aquí está el Worker más simple posible:

// worker.js self.onmessage = function(event) { const data = event.data; const result = heavyComputation(data); self.postMessage(result); }; function heavyComputation(data) { // Trabajo costoso aquí 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]);

Suficientemente simple. Pero esto genera preguntas: ¿Cómo uso esto en React? ¿Qué hay de TypeScript? ¿Cómo comparto lógica compleja? Construyamos una solución real.

Configurando Web Workers en React Moderno

Si estás usando Vite, Next.js, o Create React App, cada uno tiene soporte ligeramente diferente para Workers. Cubramos el enfoque moderno que funciona en todos.

Opción 1: Workers Inline con Blob URLs

Para casos simples, puedes crear Workers desde 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); }); // ¡Ahora esto corre en un Worker! const sum = await processInWorker([1, 2, 3, 4, 5]);

Limitación: La función no puede importar otros módulos ni usar closures. Debe ser autocontenida.

Opción 2: Soporte Nativo de Workers en Vite

Vite tiene excelente soporte para Workers con el sufijo ?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 }; }

Opción 3: Comlink para APIs Más Limpias

Comlink de Google Chrome Labs hace que los Workers se sientan como funciones async normales:

// workers/imageProcessor.worker.ts import * as Comlink from 'comlink'; const api = { async processImage(imageData: ImageData): Promise<ImageData> { // Procesamiento pesado de imagen const pixels = imageData.data; for (let i = 0; i < pixels.length; i += 4) { // Conversión a escala de grises 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 en componente function ImageEditor() { const processor = useImageProcessor(); const handleProcess = async () => { if (!processor) return; // ¡Parece una llamada async normal! const result = await processor.processImage(imageData); setProcessedImage(result); }; }

Comlink maneja toda la fontanería de postMessage y hace que los Workers se sientan naturales.

Ejemplo del Mundo Real: Parser CSV Que No Congela

Construyamos algo práctico. Imagina que estás construyendo una app que importa archivos CSV. Los usuarios suben un CSV de 50MB con 500,000 filas. Sin Workers, parsear esto congela la 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 progreso cada 1000 filas 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>Procesando... {progress}%</span> </div> )} {result && ( <div className="result"> <p>{result.rowCount.toLocaleString()} filas parseadas</p> <p>Tiempo: {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> ); }

Ahora el parseo CSV ocurre en segundo plano. La UI permanece responsive. Los usuarios ven actualizaciones de progreso. Sin congelamiento.

Manejando Datos Grandes: Transferable Objects

Copiar grandes cantidades de datos entre hilos es lento. Para ArrayBuffer, MessagePort, y OffscreenCanvas, puedes usar transferable objects para mover datos sin copiar:

// En lugar de copiar, transfiere la propiedad const buffer = new ArrayBuffer(1024 * 1024 * 100); // 100MB // Lento: copia todo el buffer worker.postMessage({ buffer }); // Rápido: transfiere propiedad (buffer queda inutilizable en el hilo principal) worker.postMessage({ buffer }, [buffer]);

Para procesamiento de imágenes, esto es enorme:

// workers/imageProcessor.worker.ts self.onmessage = async (e: MessageEvent) => { const { imageData } = e.data; // Procesar los datos de imagen const processed = applyFilter(imageData); // Transferir de vuelta en lugar de copiar const buffer = processed.data.buffer; self.postMessage({ imageData: processed }, [buffer]); };
// hilo principal const offscreen = canvas.transferControlToOffscreen(); worker.postMessage({ canvas: offscreen }, [offscreen]);

Pool de Workers para Procesamiento Paralelo

Un Worker está bien. Múltiples Workers procesando en paralelo es mejor. Aquí tienes un pool simple 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 procesamiento paralelo de imágenes:

const pool = new WorkerPool( () => new Worker(new URL('./imageWorker.ts', import.meta.url)), 4 // 4 workers paralelos ); // Procesar 100 imágenes en paralelo a través de 4 workers const results = await pool.executeAll(images);

Errores Comunes y Cómo Evitarlos

Error 1: Usar Workers en Exceso para Tareas Pequeñas

Los Workers tienen overhead. Crear un Worker, serializar datos, y deserializar resultados toma tiempo. Para operaciones pequeñas, este overhead puede exceder el tiempo de cómputo.

Regla general: Solo usa Workers cuando la operación tome >16ms (un frame a 60fps).

// ❌ No hagas esto - el overhead excede el beneficio worker.postMessage([1, 2, 3]); // Sumar 3 números // ✅ Haz esto - justifica el overhead worker.postMessage(bigArray); // Procesar 100,000 elementos

Error 2: Olvidar Terminar Workers

Los Workers consumen memoria. Si creas nuevos Workers sin terminar los viejos, tendrás memory leaks:

// ❌ Memory leak function processData(data) { const worker = new Worker('worker.js'); worker.postMessage(data); // ¡Worker nunca terminado! } // ✅ Limpia correctamente function processData(data) { const worker = new Worker('worker.js'); worker.onmessage = (e) => { console.log(e.data); worker.terminate(); // Limpia cuando termines }; worker.postMessage(data); }

En React, siempre termina en la limpieza:

useEffect(() => { const worker = new Worker('worker.js'); workerRef.current = worker; return () => { worker.terminate(); // ✅ Limpieza al desmontar }; }, []);

Error 3: No Manejar Errores

Los errores del Worker no se propagan automáticamente a tus manejadores de errores del hilo principal:

// ❌ Errores ignorados silenciosamente worker.postMessage(data); // ✅ Maneja errores explícitamente worker.onerror = (error) => { console.error('Error del Worker:', error.message); // Maneja el error apropiadamente }; worker.onmessageerror = (error) => { console.error('Error de mensaje:', error); };

Error 4: Bloquear el Hilo del Worker

Solo porque moviste trabajo a un Worker no significa que no puedas bloquearlo. Si tu Worker hace 5 segundos de trabajo síncrono, no puede responder a otros mensajes durante ese tiempo.

// ❌ Tarea síncrona larga bloquea todos los mensajes self.onmessage = (e) => { const result = process5MillionItems(e.data); // Bloquea por 5 segundos self.postMessage(result); }; // ✅ Divide el trabajo y cede periódicamente 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 otros mensajes sean procesados await new Promise(resolve => setTimeout(resolve, 0)); // Reportar progreso self.postMessage({ type: 'progress', value: i / items.length }); } self.postMessage({ type: 'complete', results }); };

Cuándo NO Usar Web Workers

Los Workers no siempre son la respuesta. Evítalos cuando:

  1. La operación es I/O bound, no CPU bound - Fetching de datos, consultas a bases de datos. Usa async/await en su lugar.

  2. Necesitas acceso al DOM - Los Workers no pueden tocar el DOM. Si necesitas actualizar la UI durante el procesamiento, debes enviar mensajes al hilo principal.

  3. El costo de transferencia de datos excede el ahorro de cómputo - Serializar 1GB de datos para ahorrar 100ms de cómputo no vale la pena.

  4. La operación ya es rápida - Si completa en <16ms, el overhead no vale la pena.

  5. Estás apuntando a navegadores viejos sin fallback - Verifica tus requisitos de soporte de navegadores.

Comparación de Rendimiento: Antes y Después

Midamos el impacto con un ejemplo real—ordenar 1 millón de números:

// Sin Worker function sortData() { const start = performance.now(); const sorted = data.sort((a, b) => a - b); console.log(`Hilo principal: ${performance.now() - start}ms`); console.log(`UI congelada durante este tiempo`); } // Con Worker async function sortDataWorker() { const start = performance.now(); const sorted = await workerSort(data); console.log(`Worker: ${performance.now() - start}ms`); console.log(`UI permaneció responsive`); }

Resultados en una máquina típica:

  • Sin Worker: 1,200ms (UI congelada)
  • Con Worker: 1,250ms (UI responsive)

El tiempo total es ligeramente más largo con Workers debido al overhead de serialización. Pero la experiencia del usuario es dramáticamente mejor porque la UI nunca se congela.

Conclusión: Saca el Dolor del Hilo Principal

Los Web Workers son una de las APIs del navegador más subutilizadas. Cuando se usan correctamente, transforman aplicaciones que se sienten lentas en unas que se sienten instantáneas y responsive.

Puntos clave:

  1. El hilo principal es precioso - Resérvalo para renderizado e interacción del usuario.

  2. Los Workers son para trabajo CPU-bound - Procesamiento de datos, parsing, cálculos, manipulación de imágenes.

  3. Usa Comlink para APIs más limpias - Hace que los Workers se sientan como funciones async normales.

  4. Transfiere, no copies - Usa transferable objects para ArrayBuffers grandes.

  5. Pool de Workers para paralelismo - Múltiples workers pueden procesar datos en paralelo.

  6. Mide antes de optimizar - Los Workers tienen overhead. Solo úsalos cuando el beneficio exceda el costo.

  7. No olvides la limpieza - Siempre termina Workers cuando hayas terminado.

La próxima vez que tu app React tartamudee durante una operación pesada, sabrás exactamente qué hacer. Mueve ese trabajo a un Worker, mantén el hilo principal libre, y dale a tus usuarios la experiencia fluida que merecen.

web-workersreactjavascriptperformancemultithreadingoptimization