El hilo principal del navegador hace demasiadas cosas: ejecuta tu JavaScript, calcula el layout, pinta y responde a los clics. Todo en la misma cola. Cuando metes ahí un cálculo pesado —parsear un CSV de miles de filas, filtrar un dataset grande, procesar una imagen— bloqueas ese hilo y la interfaz se congela: el scroll se traba, los botones no responden, el spinner ni siquiera gira porque el hilo que lo animaría está ocupado. Los Web Workers existen para eso: mover ese cómputo a otro hilo y devolverle al principal su única responsabilidad importante, que es mantener la UI viva. He usado workers en varias PWAs y la mejora en percepción es real, pero también he visto meterlos donde no tocaba. Este artículo es cuándo valen la pena.
El síntoma que delata que necesitas un worker
No todo cómputo va a un worker. La señal concreta que busco es un long task: una función síncrona que ocupa el hilo principal más de unos 50 ms y durante la cual la página deja de responder. Si abres el panel de rendimiento y ves bloques largos amarillos justo cuando la UI se traba, ahí tienes al culpable.
Casos típicos que me han llevado a un worker: parsear y transformar archivos grandes que el usuario sube, ordenar o filtrar en cliente un dataset de decenas de miles de filas, cálculos de geometría o imagen, y correr modelos ligeros en el navegador. Lo que tienen en común es que son CPU pura y prolongada, no esperas de red. Para lo que es esperar a un servidor no necesitas un worker: async/await ya libera el hilo. El worker es para trabajo que quema CPU, no para trabajo que espera.
El coste que la gente olvida: la frontera de mensajes
Un worker no comparte memoria con el hilo principal. Se comunican pasando mensajes, y esos datos se copian al cruzar la frontera (structured clone). Ese detalle es el que decide si el worker compensa o no: si mandas un objeto enorme al worker, esperas un cálculo trivial y recibes otro objeto enorme de vuelta, el coste de serializar y copiar se come la ganancia.
La regla que sigo es meter mucho trabajo por cada cruce de frontera. Un worker rinde cuando le mandas datos una vez y hace un cálculo grande, no cuando lo llamas en un bucle apretado para operaciones pequeñas. Cuando los datos son de verdad grandes, uso objetos transferibles: un ArrayBuffer se transfiere en vez de copiarse, cambiando de dueño sin duplicar memoria.
// Hilo principal: transfiere el buffer, no lo copia
const worker = new Worker(new URL('./procesar.ts', import.meta.url), { type: 'module' });
worker.postMessage({ buffer }, [buffer]); // el segundo arg transfiere la propiedad
worker.onmessage = (e) => pintarResultado(e.data);
El patrón que uso para no sufrir con postMessage
La API cruda de postMessage y onmessage se vuelve un espagueti de eventos en cuanto tienes más de dos tipos de mensaje. Lo que hago es envolver el worker en una promesa, para que desde el resto del código llamar al worker se sienta como un await normal y no como cablear un bus de eventos.
function ejecutarEnWorker<T>(worker: Worker, payload: unknown): Promise<T> {
return new Promise((resolve, reject) => {
worker.onmessage = (e) => resolve(e.data as T);
worker.onerror = (e) => reject(e);
worker.postMessage(payload);
});
}
Para casos serios uso Comlink, que hace exactamente esto pero bien: te deja llamar funciones del worker como si fueran locales, con await, y esconde toda la mensajería. La primera vez que envuelves un worker así, la barrera mental de "esto es complicado" desaparece: el worker pasa a ser una función asíncrona más.
💡 Un worker no hace tu código más rápido; hace tu UI más fluida. El cálculo tarda lo mismo o un poco más por la copia de datos. Lo que ganas es que el usuario puede seguir haciendo scroll y clicando mientras tanto. Es una mejora de percepción, no de throughput.
Dónde NO meto un worker
Ser honesto con el trade-off importa. No uso worker cuando el cómputo es corto: si tarda 5 ms, moverlo a otro hilo solo añade latencia de mensajería y una capa de complejidad para nada. Tampoco cuando el trabajo es esperar red, porque eso ya no bloquea. Y evito el worker si el algoritmo necesita tocar el DOM: los workers no tienen acceso al DOM por diseño, y si tu "cálculo" en realidad manipula la interfaz, no es candidato.
El otro coste es de tooling y mantenimiento: un worker es otro archivo, otro punto de entrada para el bundler, otra pieza que alguien tiene que entender al leer el código. Con Vite o el bundler moderno de turno la fricción bajó mucho, pero no es cero. Por eso mi umbral es claro: worker solo cuando hay un long task medible que traba la UI de forma perceptible. Si no puedo señalar ese bloque amarillo en el profiler, no hay worker. Optimizar un bloqueo que nadie nota es añadir complejidad para presumir de arquitectura, y eso no es ingeniería, es decoración.