Qué trabajo se puede mover a un worker y qué no
El criterio para decidir si una operación es candidata, qué APIs están disponibles dentro de un worker y cuáles no, el coste de arrancarlo, y los tres tipos de trabajo que dan el mejor retorno.
Un trabajador web es un hilo real con su propio bucle de eventos, su propio montículo y su propio intérprete. Lo que ejecuta dentro no toca el hilo principal en absoluto, lo cual lo convierte en la única forma de eliminar trabajo del hilo en lugar de repartirlo. La restricción que lo define es que no tiene DOM, y de esa restricción sale todo el criterio de qué se puede mover y qué no.
- Aplicar el criterio de tres preguntas que decide si algo es candidato.
- Enumerar qué APIs están y no están disponibles dentro de un trabajador.
- Cuantificar el coste de arrancar un trabajador y amortizarlo.
- Identificar los tres tipos de trabajo con mejor retorno.
El criterio de tres preguntas
¿Necesita el DOM? Si la respuesta es sí, no se puede mover. Dentro de un trabajador no existen document, window, localStorage ni ninguna API que dependa del documento. Y no hay forma de rodearlo: la restricción es de diseño, porque el DOM no es seguro para acceso concurrente y hacerlo seguro habría exigido bloqueos por todas partes.
¿Los datos de entrada y salida se pueden copiar o transferir barato? El coste de mover los datos puede superar al del cálculo. Un array de números de un megabyte se transfiere en microsegundos; un árbol de objetos anidados con cien mil nodos puede costar más en serializar que en procesar. Es el tema de el coste de postMessage.
¿El trabajo dura lo suficiente para amortizar el viaje? Cada ida y vuelta cuesta entre 0,5 y 3 milisegundos solo en el mecanismo de mensajes. Una operación de 2 milisegundos no gana nada; una de 200, sí.
Si las tres respuestas son favorables, el trabajador es la solución correcta y además la mejor: no reparte el bloqueo como el troceado, lo elimina.
Qué hay dentro de un trabajador
Lo que sí está disponible, y es más de lo que la gente cree:
fetchy toda la API de red, incluida la de flujos.IndexedDB,Cache Storage,Crypto,TextEncoderyTextDecoder.WebAssembly, con todo lo que eso implica.OffscreenCanvasy su contexto 2D y WebGL, lo que permite dibujar fuera del hilo principal.WebSocket,BroadcastChannel,MessageChannel.performance,setTimeout,structuredClone,URL,Blob,FileReader.importScriptsen trabajadores clásicos, eimportestático y dinámico en trabajadores de módulo.
Lo que no está:
document,window,parent, y todo el DOM.localStorageysessionStorage. El almacenamiento síncrono no está disponible por diseño; la alternativa esIndexedDB.alert,confirm,prompt.- Las APIs que exigen contexto de documento, como las de historial o las de permisos con interfaz.
La lista de disponibles explica los tres casos de uso que mejor funcionan.
Los tres tipos de trabajo con mejor retorno
Uno: transformación de datos grandes. Parsear, filtrar, ordenar, agregar. Una tabla de cincuenta mil filas que hay que ordenar por tres criterios, un CSV de diez megas que hay que convertir, un conjunto de datos que hay que agrupar. La entrada y la salida suelen ser arrays de tipos primitivos o de objetos planos, y la ganancia es directa.
Dos: cálculo intensivo. Compresión, cifrado, procesamiento de imagen, análisis de audio, cálculo geoespacial, simulación. Es donde WebAssembly brilla, y el trabajador es su hogar natural.
Tres: la red y su procesamiento. Descargar, parsear y normalizar una respuesta grande sin tocar el hilo principal. El fetch en sí no bloquea, pero el await respuesta.json() de un documento de cinco megas sí, y ese trabajo se mueve entero.
Este tercer caso es el más infravalorado y el más fácil de aplicar:
// datos.worker.js
self.addEventListener('message', async (e) => {
const { id, url } = e.data;
try {
const r = await fetch(url);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const bruto = await r.json(); // parseo fuera del hilo principal
const normalizado = normalizar(bruto); // transformacion tambien
self.postMessage({ id, ok: true, datos: normalizado });
} catch (err) {
self.postMessage({ id, ok: false, error: String(err) });
}
});
function normalizar(filas) {
return filas.map((f) => ({
id: f.identificador,
nombre: f.nombre_completo.trim(),
total: Number(f.importe_total),
fecha: Date.parse(f.fecha_iso),
}));
}
// hilo principal
const trabajador = new Worker(new URL('./datos.worker.js', import.meta.url), { type: 'module' });
const pendientes = new Map();
let siguienteId = 0;
trabajador.addEventListener('message', (e) => {
const { id, ok, datos, error } = e.data;
const p = pendientes.get(id);
if (!p) return;
pendientes.delete(id);
ok ? p.resolver(datos) : p.rechazar(new Error(error));
});
export function pedirDatos(url) {
const id = siguienteId++;
return new Promise((resolver, rechazar) => {
pendientes.set(id, { resolver, rechazar });
trabajador.postMessage({ id, url });
});
}
Dos detalles del código que importan.
new URL('./datos.worker.js', import.meta.url) es la forma correcta de referenciar el fichero del trabajador: los empaquetadores modernos la reconocen y generan el fichero como un artefacto propio con su hash. Una cadena literal no se resuelve bien y rompe en producción.
El identificador de correlación es imprescindible en cuanto hay más de una petición en vuelo. Sin él, no sabes qué respuesta corresponde a qué pregunta. Esta plomería es exactamente lo que resuelve Comlink.
El coste de arrancar
Crear un trabajador no es gratis. El navegador tiene que crear un contexto de ejecución nuevo, con su propio montículo y su propio intérprete, y después descargar, parsear, compilar y ejecutar su código.
Las cifras de referencia: entre 10 y 40 milisegundos para el arranque del contexto en un dispositivo de gama media, más el coste del código del trabajador, que se paga igual que el del hilo principal —descarga, parseo, compilación, ejecución— aunque en un hilo aparte.
De ahí tres consecuencias prácticas.
No crees un trabajador por operación. Créalo una vez y reutilízalo. Un trabajador vivo consume memoria y poco más.
Créalo antes de necesitarlo, no en el momento de la interacción. Arrancarlo dentro de un manejador de clic añade sus 30 milisegundos al procesamiento. Lo correcto es crearlo en tiempo ocioso tras la carga.
let trabajadorPromesa = null;
export function obtenerTrabajador() {
return (trabajadorPromesa ??= new Promise((resolver) => {
const crear = () => resolver(new Worker(new URL('./datos.worker.js', import.meta.url), { type: 'module' }));
if ('requestIdleCallback' in globalThis) requestIdleCallback(crear, { timeout: 2000 });
else setTimeout(crear, 500);
}));
}
Mantén el código del trabajador pequeño. Todo lo que importe se descarga y se compila. Un trabajador que arrastra media aplicación tarda tanto en estar listo como el hilo principal.
La regla de que sin DOM no hay renderizado tiene una excepción grande que mucha gente desconoce y que abre la puerta a mover a un hilo aparte trabajo que parecía imposible: OffscreenCanvas.
Un <canvas> del documento principal se puede transferir a un trabajador, y a partir de ese momento el trabajador dibuja en él directamente, con el contexto 2D o con WebGL, sin pasar por el hilo principal para nada. El resultado aparece en la página igual que si lo hubiera dibujado el hilo principal.
// hilo principal: transferir el control del canvas
const lienzo = document.querySelector('#grafico');
const desplazado = lienzo.transferControlToOffscreen();
const trabajador = new Worker(new URL('./dibujo.worker.js', import.meta.url), { type: 'module' });
trabajador.postMessage({ tipo: 'init', lienzo: desplazado }, [desplazado]);// dibujo.worker.js
let ctx = null;
self.addEventListener('message', (e) => {
if (e.data.tipo === 'init') {
ctx = e.data.lienzo.getContext('2d');
requestAnimationFrame(pintar); // rAF existe dentro del worker
}
if (e.data.tipo === 'datos') datos = e.data.valores;
});
let datos = [];
function pintar() {
const { width: w, height: h } = ctx.canvas;
ctx.clearRect(0, 0, w, h);
ctx.beginPath();
for (let i = 0; i < datos.length; i++) {
const x = (i / (datos.length - 1)) * w;
const y = h - datos[i] * h;
i ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
}
ctx.strokeStyle = '#89b4fa';
ctx.lineWidth = 2;
ctx.stroke();
requestAnimationFrame(pintar);
}El array del corchete en el postMessage es la lista de transferibles, y sin ella la transferencia no ocurre. Una vez transferido, el canvas del hilo principal queda inutilizable: cualquier intento de obtener su contexto lanza una excepción. Es una transferencia, no una copia.
Lo que esto habilita, y es mucho:
Gráficos y visualizaciones que se redibujan constantemente sin consumir hilo principal. Un panel con seis gráficos animados deja de competir con la interacción del usuario.
Renderizado 3D en un hilo aparte, con WebGL o WebGPU dentro del trabajador. Es el caso donde la ganancia es más espectacular, porque el bucle de renderizado deja de disputar el hilo con el resto de la aplicación.
Procesamiento de imagen sobre píxeles, con createImageBitmap para decodificar y el contexto 2D para transformar, todo fuera del hilo principal.
Las dos limitaciones que hay que conocer antes de comprometerse: la entrada del usuario sigue llegando al hilo principal, así que los eventos de ratón sobre el canvas hay que reenviarlos al trabajador por mensaje, con su latencia. Y la disponibilidad: OffscreenCanvas está en Chromium desde hace años, en Firefox y en Safari desde versiones recientes, así que hay que comprobar typeof OffscreenCanvas !== 'undefined' y mantener el camino de dibujo en el hilo principal como respaldo. Escribir la función de dibujo de forma que sirva para los dos contextos es lo que hace ese respaldo barato.
Identifica en tu aplicación una operación que cumpla las tres preguntas: sin DOM, con datos copiables, y de más de 100 milisegundos. Impleméntala en un trabajador con el patrón de identificador de correlación, creando el trabajador en tiempo ocioso. Mide tres cifras antes y después: la tarea más larga del hilo principal, el tiempo total de la operación, y el retraso de entrada de un clic disparado mientras la operación corre.