El problema de tener un solo hilo en el navegador
JavaScript en el navegador ejecuta el código de tu aplicación en un único hilo, el mismo que se encarga de pintar la interfaz, responder a los clics y procesar el desplazamiento. Mientras ese hilo está ocupado calculando algo, todo lo demás espera: los botones no responden, las animaciones se congelan y, si la tarea dura lo suficiente, el navegador muestra el temido aviso de página que no responde.
Para la mayoría de las operaciones esto no supone un problema, porque las tareas son breves y las operaciones de red ya son asíncronas. El conflicto aparece cuando la aplicación necesita procesar mucha información en el cliente: parsear un JSON de varios megas, filtrar decenas de miles de filas, comprimir una imagen, calcular estadísticas sobre un flujo de datos en tiempo real o mantener una conexión que recibe mensajes constantemente. En esos casos, cada milisegundo que el código pasa en el hilo principal del navegador es un milisegundo en el que la interfaz deja de responder.
Los Web Workers son la respuesta estándar de la plataforma web a ese problema. Permiten ejecutar JavaScript en hilos en segundo plano, aislados del hilo de la interfaz, y comunicarse con ellos mediante mensajes. Son una API estable, compatible con todos los navegadores modernos desde hace años, que no requiere librerías externas.
En esta guía verás cómo funcionan los workers por dentro, qué tipos existen, qué pueden y qué no pueden hacer, y cómo construir paso a paso un caso realista: un panel en React que recibe lecturas de sensores por WebSocket y delega toda la gestión de la conexión a un worker. El proyecto usa React 19, Vite 8 para el empaquetado y Node.js 26, la versión LTS activa actual, para el servidor de sockets.
Qué es exactamente un Web Worker
Un Web Worker es un script que el navegador ejecuta en un hilo del sistema operativo distinto del hilo principal. Ese script tiene su propio contexto global, su propio bucle de eventos y su propia memoria. No comparte variables con la página: la única forma de intercambiar información es enviar mensajes en ambas direcciones.
Crear uno es sencillo. Desde el código de la página basta con instanciar la clase Worker indicando el archivo que debe ejecutar:
// main.js
const worker = new Worker(new URL("./calculo.worker.js", import.meta.url), {
type: "module",
});
worker.postMessage({ numeros: [3, 9, 27, 81] });
worker.onmessage = (evento) => {
console.log("Resultado del worker:", evento.data);
};
Y dentro del worker, el objeto global ya no es window, sino self, que representa el propio contexto del worker:
// calculo.worker.js
self.onmessage = (evento) => {
const { numeros } = evento.data;
const suma = numeros.reduce((total, n) => total + n, 0);
self.postMessage({ suma, promedio: suma / numeros.length });
};
Resultado del worker: { suma: 120, promedio: 30 }
La opción type: "module" convierte el worker en un módulo ES, lo que permite usar import y export dentro de él. Hoy es la forma recomendada de crearlos; los workers clásicos, que cargaban dependencias con importScripts(), siguen funcionando, pero solo tienen sentido en código heredado.
Por qué usar new URL con import.meta.url
En el ejemplo anterior la ruta del worker no se pasa como texto plano, sino envuelta en new URL(..., import.meta.url). No es un capricho estético. Los empaquetadores modernos, como Vite, webpack 5 o Rollup, reconocen exactamente ese patrón durante la compilación: detectan que el archivo referenciado es un worker, lo procesan como un punto de entrada independiente, resuelven sus dependencias y generan la ruta final con el nombre que tendrá en producción.
Si en lugar de eso escribes new Worker("./calculo.worker.js"), el empaquetador no sabe que ese archivo existe. En desarrollo puede funcionar por casualidad, pero al compilar el archivo no se copia o queda en una ruta distinta, y el navegador devuelve un error 404 al intentar cargarlo.
Vite también ofrece una sintaxis propia basada en sufijos de importación, útil cuando prefieres tratar el worker como un módulo más:
import SensoresWorker from "./sensores.worker.js?worker";
const worker = new SensoresWorker();
Ambas formas funcionan, pero el patrón con new URL tiene la ventaja de ser estándar y portable entre empaquetadores, por lo que es el que usaremos en el resto del artículo.
Tipos de workers y en qué se diferencian
Cuando se habla de workers en el navegador, en realidad se agrupan varias APIs con propósitos distintos.
Los workers dedicados (Worker) pertenecen a un único script que los crea. Son los más habituales y los que cubren la gran mayoría de casos de uso: cálculo intensivo, parseo de datos, gestión de conexiones.
Los workers compartidos (SharedWorker) pueden ser utilizados por varias páginas, pestañas o iframes del mismo origen a la vez. Son útiles, por ejemplo, para mantener una única conexión WebSocket compartida entre todas las pestañas abiertas de una aplicación:
// En cada pestaña
const compartido = new SharedWorker(
new URL("./canal.worker.js", import.meta.url),
{
type: "module",
}
);
compartido.port.start();
compartido.port.postMessage({ tipo: "suscribir", canal: "alertas" });
compartido.port.onmessage = (evento) => console.log(evento.data);
// canal.worker.js
const puertos = new Set();
self.onconnect = (evento) => {
const puerto = evento.ports[0];
puertos.add(puerto);
puerto.onmessage = (mensaje) => {
for (const p of puertos) {
p.postMessage({ origen: "compartido", recibido: mensaje.data });
}
};
};
A diferencia del worker dedicado, la comunicación con un worker compartido pasa siempre por un objeto port. Conviene tener en cuenta que el soporte de SharedWorker en navegadores móviles ha sido históricamente más irregular que el de los workers dedicados, así que antes de basar una arquitectura en ellos merece la pena revisar la compatibilidad de tu público objetivo.
Los Service Workers son otra categoría: actúan como un proxy entre la aplicación y la red, interceptan peticiones, gestionan caché y habilitan notificaciones push y funcionamiento sin conexión. Comparten el modelo de mensajes, pero su ciclo de vida y su propósito son completamente distintos, y no están pensados para cálculo en segundo plano.
Por último, los Worklets (de audio o de pintura) son contextos ligeros y muy especializados que se enganchan a partes concretas del motor de renderizado o de audio.
| API | Quién la usa | Caso de uso típico |
|---|---|---|
Worker | Un solo script | Cálculo pesado, parseo, conexiones |
SharedWorker | Varias pestañas del mismo origen | Conexión o estado compartido |
| Service Worker | Todo el sitio | Caché, modo sin conexión, push |
| Worklet | Motor de audio o CSS | Procesamiento de audio, pintura |
Qué puede y qué no puede hacer un worker
El aislamiento que da a los workers su ventaja también define sus límites. Un worker no tiene acceso al DOM: no puede leer ni modificar elementos, ni usar document, ni window, ni localStorage. Todo cambio visual debe hacerlo el hilo principal a partir de los mensajes que recibe.
Lo que sí está disponible dentro de un worker es bastante amplio:
fetchyXMLHttpRequestpara peticiones de red.WebSocketyEventSourcepara conexiones persistentes.IndexedDBy la API de caché para almacenamiento.- Temporizadores como
setTimeoutysetInterval. cryptoycrypto.subtlepara operaciones criptográficas.OffscreenCanvaspara dibujar gráficos fuera del hilo principal.WebAssemblypara ejecutar código compilado.
Esta lista explica por qué un worker es un buen lugar para la lógica de datos de una aplicación: puede descargar información, mantener conexiones y almacenar resultados sin intervención de la interfaz.
El modelo de mensajes y el algoritmo de clonado
La comunicación con postMessage no comparte objetos: los copia. El navegador usa el algoritmo de clonado estructurado (structured clone), que admite tipos primitivos, objetos y arreglos, Date, Map, Set, RegExp, Blob, File, ArrayBuffer y tipos tipados, entre otros. No admite funciones, nodos del DOM ni instancias de clases con sus métodos: estas últimas llegan como objetos planos.
worker.postMessage({
creado: new Date(),
etiquetas: new Set(["sala", "planta-2"]),
valores: new Float32Array([21.4, 21.9, 22.3]),
});
// Esto lanza DataCloneError
worker.postMessage({ formatear: (x) => x.toFixed(2) });
Uncaught DataCloneError: Failed to execute 'postMessage' on 'Worker': (x) => x.toFixed(2) could not be cloned.
Copiar datos pequeños es prácticamente gratuito, pero copiar un buffer de cien megas tiene un coste real. Para esos casos existen los objetos transferibles: en lugar de copiar, el navegador transfiere la propiedad del bloque de memoria al otro hilo, y el emisor deja de poder usarlo.
const pixeles = new Uint8ClampedArray(1920 * 1080 * 4);
worker.postMessage({ pixeles }, [pixeles.buffer]);
console.log(pixeles.byteLength); // 0: el buffer ya pertenece al worker
Una alternativa moderna es structuredClone(), disponible tanto en el navegador como en Node.js, que aplica el mismo algoritmo sin necesidad de un worker. Resulta útil para probar si un objeto se podrá enviar antes de intentarlo.
Caso práctico: panel de sensores en tiempo real
Para ver todas estas piezas trabajando juntas construiremos un panel de monitorización. Un servidor Node.js simula sensores de temperatura y envía una lectura cada segundo y medio por WebSocket. En el navegador, un worker se encarga de abrir y cerrar la conexión, recibir las lecturas y calcular una media móvil, mientras React solo pinta el gráfico y el registro de eventos.
La separación de responsabilidades es la clave del diseño: la interfaz nunca toca el socket, y el worker nunca toca la interfaz. Todo se comunica con mensajes de un formato bien definido.
La estructura del proyecto es la siguiente:
panel-sensores/
├── servidor/
│ ├── package.json
│ └── servidor.mjs
└── cliente/
├── index.html
├── package.json
├── vite.config.js
└── src/
├── main.jsx
├── App.jsx
├── hooks/
│ └── useSensoresWorker.js
├── components/
│ ├── GraficoLecturas.jsx
│ └── RegistroEventos.jsx
└── workers/
└── sensores.worker.js
Un apunte sobre Create React App
Muchos tutoriales sobre workers en React se escribieron con Create React App. Esa herramienta fue declarada obsoleta por el equipo de React a comienzos de 2025 y ya no se recomienda para proyectos nuevos. La documentación oficial sugiere usar un framework o un empaquetador moderno; para una aplicación de cliente como esta, Vite es la opción más directa, y además maneja los workers con el patrón new URL sin ninguna configuración adicional.
Crea el cliente con la plantilla de React de Vite e instala Recharts para el gráfico:
npm create vite@latest cliente -- --template react
cd cliente
npm install
npm install recharts
Y prepara el servidor con la librería ws, que sigue siendo la implementación de servidor WebSocket más extendida en Node.js. Aunque Node.js incluye un cliente WebSocket global desde hace varias versiones, no ofrece un servidor nativo:
mkdir servidor && cd servidor
npm init -y
npm install ws
El servidor de sockets en Node.js
El servidor espera a que el cliente envíe un mensaje de inicio y, a partir de ese momento, emite una lectura simulada cada 1,5 segundos. Al cerrarse la conexión, detiene el temporizador para no dejar intervalos huérfanos:
// servidor/servidor.mjs
import { WebSocketServer } from "ws";
const PUERTO = 8080;
const servidor = new WebSocketServer({ port: PUERTO });
servidor.on("connection", (socket) => {
console.log("Cliente conectado");
let temporizador = null;
socket.on("message", (datos) => {
const mensaje = JSON.parse(datos.toString());
if (mensaje.tipo === "iniciar" && !temporizador) {
temporizador = setInterval(() => {
const lectura = {
sensor: "sala-servidores",
temperatura: Number((20 + Math.random() * 8).toFixed(2)),
marcaTiempo: Date.now(),
};
socket.send(JSON.stringify(lectura));
}, 1500);
}
});
socket.on("close", () => {
clearInterval(temporizador);
console.log("Cliente desconectado");
});
});
console.log(`Servidor de sensores escuchando en ws://localhost:${PUERTO}`);
node servidor.mjs
Servidor de sensores escuchando en ws://localhost:8080
Cliente conectado
El worker que gestiona la conexión
El worker es el corazón del sistema. Recibe órdenes de la interfaz (conectar o desconectar), gestiona el ciclo de vida del socket y transforma cada lectura antes de enviarla de vuelta. Para que el protocolo sea claro, todos los mensajes que salen del worker llevan un campo tipo:
// cliente/src/workers/sensores.worker.js
const URL_SERVIDOR = "ws://localhost:8080";
const VENTANA_MEDIA = 5;
let socket = null;
const ultimas = [];
function informar(tipo, carga) {
self.postMessage({ tipo, carga, enviado: Date.now() });
}
function calcularMediaMovil(valor) {
ultimas.push(valor);
if (ultimas.length > VENTANA_MEDIA) ultimas.shift();
const suma = ultimas.reduce((total, v) => total + v, 0);
return Number((suma / ultimas.length).toFixed(2));
}
function conectar() {
if (socket && socket.readyState <= WebSocket.OPEN) {
informar("registro", "La conexión ya está activa");
return;
}
socket = new WebSocket(URL_SERVIDOR);
socket.onopen = () => {
socket.send(JSON.stringify({ tipo: "iniciar" }));
informar("estado", { conectado: true });
informar("registro", "Conexión abierta con el servidor");
};
socket.onmessage = (evento) => {
const lectura = JSON.parse(evento.data);
informar("lectura", {
...lectura,
media: calcularMediaMovil(lectura.temperatura),
});
};
socket.onclose = (evento) => {
informar("estado", { conectado: false });
informar(
"registro",
evento.wasClean
? `Conexión cerrada correctamente (código ${evento.code})`
: "La conexión se interrumpió de forma inesperada"
);
socket = null;
};
socket.onerror = () => {
informar("registro", "Error en la conexión WebSocket");
};
}
function desconectar() {
if (socket) {
socket.close(1000, "Cierre solicitado por el usuario");
}
}
self.onmessage = (evento) => {
const { accion } = evento.data;
switch (accion) {
case "conectar":
conectar();
break;
case "desconectar":
desconectar();
break;
default:
informar("registro", `Acción desconocida: ${accion}`);
}
};
Hay varias decisiones de diseño que merece la pena destacar. La variable socket vive en el nivel superior del módulo, no dentro del manejador de mensajes. Si se creara dentro de onmessage, cada mensaje de la interfaz abriría una conexión nueva y no habría forma de cerrar las anteriores. Al ser un módulo, esa variable es privada: ningún código externo puede acceder a ella si no es a través de los mensajes que el worker decide aceptar.
La segunda decisión es que el manejador de mensajes solo enruta: decide qué función llamar según la acción recibida, y la lógica concreta vive en funciones separadas. Esto hace el worker fácil de leer y de ampliar con nuevas acciones.
La tercera es que el cálculo de la media móvil ocurre en el worker. En este ejemplo el cálculo es trivial, pero en un caso real podría ser un filtrado, una detección de anomalías o una agregación sobre miles de lecturas, y la interfaz no tendría que pagar ese coste.
Encapsular el worker en un hook de React
En React, el lugar natural para crear y destruir un worker es un efecto. Encapsularlo en un hook personalizado mantiene los componentes limpios y garantiza que el worker se termina cuando el componente se desmonta:
// cliente/src/hooks/useSensoresWorker.js
import { useCallback, useEffect, useRef, useState } from "react";
const MAX_PUNTOS = 30;
export function useSensoresWorker() {
const workerRef = useRef(null);
const [lecturas, setLecturas] = useState([]);
const [registro, setRegistro] = useState([]);
const [conectado, setConectado] = useState(false);
useEffect(() => {
const worker = new Worker(
new URL("../workers/sensores.worker.js", import.meta.url),
{ type: "module" }
);
workerRef.current = worker;
worker.onmessage = ({ data }) => {
switch (data.tipo) {
case "lectura":
setLecturas((previas) =>
[...previas, data.carga].slice(-MAX_PUNTOS)
);
break;
case "estado":
setConectado(data.carga.conectado);
break;
case "registro":
setRegistro((previo) => [
`${new Date(data.enviado).toLocaleTimeString()} · ${data.carga}`,
...previo,
]);
break;
}
};
worker.onerror = (error) => {
setRegistro((previo) => [
`Error en el worker: ${error.message}`,
...previo,
]);
};
return () => {
worker.terminate();
workerRef.current = null;
};
}, []);
const conectar = useCallback(() => {
workerRef.current?.postMessage({ accion: "conectar" });
}, []);
const desconectar = useCallback(() => {
workerRef.current?.postMessage({ accion: "desconectar" });
}, []);
return { lecturas, registro, conectado, conectar, desconectar };
}
Guardar el worker en un useRef en lugar de en el estado evita renderizados innecesarios, porque la referencia al worker nunca necesita mostrarse en la interfaz. La función de limpieza del efecto llama a terminate(), que detiene el hilo de inmediato y libera sus recursos, incluido el socket abierto.
Un detalle importante: en desarrollo, el modo estricto de React monta, desmonta y vuelve a montar los componentes para detectar efectos sin limpieza. Si ves en la consola que el worker se crea dos veces, no es un error: es precisamente esa comprobación, y la limpieza con terminate() garantiza que solo quede uno activo.
La interfaz: gráfico y registro de eventos
Con la lógica encapsulada en el hook, el componente principal queda muy simple. Solo decide qué mostrar y qué acción enviar al pulsar cada botón:
// cliente/src/App.jsx
import { useSensoresWorker } from "./hooks/useSensoresWorker";
import GraficoLecturas from "./components/GraficoLecturas";
import RegistroEventos from "./components/RegistroEventos";
export default function App() {
const { lecturas, registro, conectado, conectar, desconectar } =
useSensoresWorker();
return (
<main className="panel">
<header>
<h1>Monitor de temperatura</h1>
<div className="acciones">
<button onClick={conectar} disabled={conectado}>
Conectar
</button>
<button onClick={desconectar} disabled={!conectado}>
Desconectar
</button>
</div>
</header>
<GraficoLecturas datos={lecturas} />
<RegistroEventos entradas={registro} />
</main>
);
}
El gráfico usa Recharts para dibujar dos líneas: la temperatura instantánea y la media móvil calculada por el worker:
// cliente/src/components/GraficoLecturas.jsx
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
export default function GraficoLecturas({ datos }) {
const puntos = datos.map((d) => ({
...d,
hora: new Date(d.marcaTiempo).toLocaleTimeString(),
}));
return (
<ResponsiveContainer width="100%" height={320}>
<LineChart data={puntos}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="hora" />
<YAxis domain={[18, 30]} unit="°C" />
<Tooltip />
<Line type="monotone" dataKey="temperatura" dot={false} />
<Line
type="monotone"
dataKey="media"
strokeDasharray="5 5"
dot={false}
/>
</LineChart>
</ResponsiveContainer>
);
}
Y el registro de eventos simplemente lista los mensajes que el worker ha ido enviando:
// cliente/src/components/RegistroEventos.jsx
export default function RegistroEventos({ entradas }) {
return (
<section className="registro">
<h2>Eventos</h2>
<ul>
{entradas.map((entrada, indice) => (
<li key={indice}>{entrada}</li>
))}
</ul>
</section>
);
}
Con el servidor en marcha, inicia el cliente con npm run dev. Al pulsar Conectar, el registro mostrará una secuencia como esta:
09:52:10 · Conexión abierta con el servidor
09:52:41 · Conexión cerrada correctamente (código 1000)
El recorrido completo de un mensaje
Vale la pena seguir el flujo de principio a fin para entender el papel de cada pieza. Cuando el usuario pulsa Conectar, el componente llama a la función del hook, que envía { accion: "conectar" } al worker. El worker recibe el mensaje en su manejador, lo enruta a conectar() y abre el socket. Cuando la conexión se establece, envía al servidor la orden de iniciar y notifica a la interfaz que ya está conectado, lo que desactiva el botón.
A partir de ahí, cada lectura del servidor llega al worker, que la parsea, calcula la media móvil y la reenvía a la interfaz con el tipo lectura. El hook la añade al estado, limitando el historial a los últimos treinta puntos, y React vuelve a dibujar el gráfico. En ningún momento el hilo principal se encarga de parsear mensajes de red ni de calcular estadísticas: solo recibe datos listos para mostrar.
Al pulsar Desconectar, el proceso se invierte: el worker cierra el socket con un código 1000 (cierre normal), el servidor detiene su temporizador y el evento onclose notifica a la interfaz para volver a habilitar el botón de conexión. Esta forma de comunicación con postMessage entre hilos mantiene cada parte desacoplada y fácil de probar por separado.
Cálculo intensivo: cuándo la diferencia se nota de verdad
El panel de sensores muestra el patrón de arquitectura, pero el beneficio de rendimiento se aprecia mejor con una tarea que realmente bloquee. Compara estas dos versiones de una búsqueda de números primos:
// primos.js — función compartida
export function contarPrimos(limite) {
let cantidad = 0;
for (let n = 2; n <= limite; n++) {
let esPrimo = true;
for (let d = 2; d * d <= n; d++) {
if (n % d === 0) {
esPrimo = false;
break;
}
}
if (esPrimo) cantidad++;
}
return cantidad;
}
// Versión bloqueante: la interfaz se congela durante el cálculo
import { contarPrimos } from "./primos.js";
botonBloqueante.onclick = () => {
const inicio = performance.now();
const total = contarPrimos(20_000_000);
salida.textContent = `${total} primos en ${Math.round(performance.now() - inicio)} ms`;
};
// primos.worker.js — versión en segundo plano
import { contarPrimos } from "./primos.js";
self.onmessage = ({ data }) => {
const inicio = performance.now();
const total = contarPrimos(data.limite);
self.postMessage({ total, ms: Math.round(performance.now() - inicio) });
};
// Versión no bloqueante: la interfaz sigue respondiendo
const worker = new Worker(new URL("./primos.worker.js", import.meta.url), {
type: "module",
});
botonWorker.onclick = () => worker.postMessage({ limite: 20_000_000 });
worker.onmessage = ({ data }) => {
salida.textContent = `${data.total} primos en ${data.ms} ms`;
};
El tiempo de cálculo es prácticamente el mismo en ambos casos; lo que cambia es que, con el worker, puedes seguir desplazándote, escribiendo en un campo de texto o viendo una animación mientras el resultado se calcula. Los workers no hacen que el código sea más rápido: hacen que la interfaz siga siendo usable mientras el código trabaja.
Simplificar la comunicación con Comlink
A medida que un worker expone más operaciones, mantener a mano el protocolo de mensajes con switch y campos tipo se vuelve repetitivo. Comlink, una pequeña librería mantenida por el equipo de Chrome, resuelve esto convirtiendo el worker en un objeto cuyos métodos puedes llamar como si fueran funciones asíncronas normales:
npm install comlink
// estadisticas.worker.js
import * as Comlink from "comlink";
const api = {
resumen(valores) {
const ordenados = [...valores].sort((a, b) => a - b);
const suma = ordenados.reduce((t, v) => t + v, 0);
return {
minimo: ordenados[0],
maximo: ordenados.at(-1),
media: suma / ordenados.length,
mediana: ordenados[Math.floor(ordenados.length / 2)],
};
},
};
Comlink.expose(api);
// main.js
import * as Comlink from "comlink";
const worker = new Worker(
new URL("./estadisticas.worker.js", import.meta.url),
{
type: "module",
}
);
const estadisticas = Comlink.wrap(worker);
const resultado = await estadisticas.resumen([22.1, 24.8, 21.3, 26.0, 23.4]);
console.log(resultado);
{ minimo: 21.3, maximo: 26, media: 23.52, mediana: 23.4 }
Por debajo, Comlink sigue usando postMessage; simplemente genera el protocolo por ti. Es una buena elección cuando el worker funciona como un servicio con varias operaciones. Para casos con un flujo continuo de eventos, como el del socket, el modelo de mensajes explícito suele ser más claro.
Errores comunes y cómo resolverlos
Algunos fallos aparecen una y otra vez al empezar con workers.
El worker no se encuentra al compilar. Si en producción ves un error de carga del script del worker, revisa que estés usando new URL("./archivo.js", import.meta.url) y no una cadena literal:
GET https://midominio.com/sensores.worker.js 404 (Not Found)
document o window no están definidos. Algún código del worker, o una librería que importas desde él, intenta acceder al DOM:
Uncaught ReferenceError: document is not defined
Mueve esa parte al hilo principal o busca una librería que no dependa del navegador. Para comprobar en qué contexto se ejecuta un módulo compartido, puedes usar typeof WorkerGlobalScope !== "undefined".
import no está permitido dentro del worker. Ocurre cuando creas el worker sin type: "module":
Uncaught SyntaxError: Cannot use import statement outside a module
Error al enviar datos. Intentar enviar funciones, elementos del DOM u objetos con referencias no clonables produce DataCloneError. Envía solo datos planos y reconstruye los objetos en el otro lado si es necesario.
Fugas de workers. Si creas un worker cada vez que se ejecuta una acción y nunca llamas a terminate(), los hilos se acumulan y consumen memoria. Crea los workers una vez, reutilízalos y termínalos cuando dejen de ser necesarios.
Errores silenciosos. Una excepción no capturada dentro del worker no detiene la página, y si no escuchas el evento error puede pasar desapercibida. Registra siempre un manejador onerror en el hilo principal, y considera también onmessageerror, que se dispara cuando un mensaje no puede deserializarse.
Para depurar, las herramientas de desarrollo de Chrome, Edge y Firefox muestran cada worker como un contexto independiente en el panel de fuentes, con sus propios puntos de interrupción, y permiten seleccionarlo en el desplegable de la consola para evaluar expresiones dentro de él.
Buenas prácticas para workers en producción
Algunas recomendaciones ayudan a sacar partido de los workers sin complicar la arquitectura:
- Define un protocolo de mensajes explícito, con un campo de tipo o acción, y documenta qué mensajes acepta y emite cada worker.
- Crea los workers una sola vez y reutilízalos; arrancar un hilo tiene un coste de inicialización.
- Usa objetos transferibles para buffers grandes en lugar de copiarlos.
- Agrupa mensajes muy frecuentes. Enviar miles de mensajes por segundo al hilo principal puede saturarlo igual que el cálculo original; acumula resultados y envíalos por lotes.
- Para tareas paralelizables, considera un pequeño grupo de workers dimensionado con
navigator.hardwareConcurrency, sin superar el número de núcleos disponibles. - Termina los workers en la limpieza de los componentes o de las rutas.
- Mantén la lógica pura en módulos sin dependencias del navegador; así puedes probarla con un ejecutor de pruebas normal, sin necesidad de levantar workers.
En cuanto a seguridad, los workers están sujetos a la política del mismo origen: el script debe servirse desde el mismo origen que la página, y respetan la política de seguridad de contenido (CSP) mediante la directiva worker-src. Si tu aplicación usa una CSP estricta, asegúrate de incluir esa directiva o los workers no podrán cargarse.
Cuándo no merece la pena usar un worker
Un worker no es gratis: añade un archivo más, un protocolo de mensajes y el coste de serializar datos en cada viaje. Para tareas que duran pocos milisegundos, ese coste puede superar al beneficio. Si el problema es una petición de red lenta, el código ya es asíncrono y un worker no aportará nada.
Antes de introducir uno, mide. El panel de rendimiento de las herramientas de desarrollo marca como tareas largas cualquier bloque que ocupe el hilo principal más de 50 milisegundos. Si tus interacciones generan tareas largas por cálculo en JavaScript, un worker es la solución adecuada. Si el problema son renderizados de React costosos, herramientas como la memorización, la virtualización de listas o las transiciones de React suelen ser un mejor primer paso.
En el lado del servidor, el equivalente de los web workers en JavaScript es el módulo worker_threads de Node.js, con una API muy parecida basada en mensajes. Lo aprendido en el navegador se traslada casi directamente a ese entorno.
Conclusiones
Los Web Workers resuelven una limitación fundamental del navegador: la de tener un solo hilo para ejecutar el código de la aplicación y dibujar la interfaz. Al mover el trabajo pesado a hilos en segundo plano, la interfaz sigue respondiendo aunque la aplicación esté procesando datos, manteniendo conexiones o realizando cálculos intensivos.
El modelo es sencillo una vez se entienden sus reglas: cada worker tiene su propio contexto, no accede al DOM y se comunica exclusivamente mediante mensajes clonados o transferidos. Con un protocolo de mensajes claro, una variable de estado en el ámbito del módulo y una limpieza disciplinada con terminate(), es posible construir arquitecturas como la del panel de sensores, donde la interfaz y la lógica de datos están completamente desacopladas.
Con React 19, Vite 8 y Node.js 26 como base, crear workers es hoy más sencillo que nunca gracias al patrón new URL con import.meta.url, y librerías como Comlink eliminan buena parte del código repetitivo. El siguiente paso natural es identificar en tus propias aplicaciones las tareas largas que bloquean el hilo principal y probar a moverlas a un worker, midiendo antes y después para confirmar la mejora.
