Qué es un Worker: un hilo de verdad, sin DOM
Un Worker no simula concurrencia: es un hilo real del sistema con su propio bucle de eventos y su propio objeto global, sin acceso al documento ni a nada que huela a interfaz.
La frase que todo el mundo repite —JavaScript es de un solo hilo— es una verdad a medias que caduca en el instante en que escribes new Worker. Lo que es de un solo hilo es cada agente: un espacio de ejecución con su pila, su memoria y su bucle de eventos, dentro del cual jamás hay dos cosas ocurriendo a la vez. La plataforma web, en cambio, lleva desde 2009 dejándote crear varios agentes y ejecutarlos de verdad en paralelo, sobre hilos que planifica el sistema operativo y que en una máquina con varios núcleos corren simultáneamente, no por turnos. Un Worker no es una cola de tareas disfrazada ni una corrutina con nombre pomposo: es un segundo intérprete completo, con su propio calentamiento, su propio recolector de basura y su propio catálogo de APIs, deliberadamente amputado de todo lo que toque el documento.
- Distinguir agente, hilo y bucle de eventos, y ver por qué un Worker es las tres cosas a la vez.
- Inventariar qué APIs existen dentro de un Worker y cuáles se han retirado a propósito.
- Entender por qué bloquear dentro de un Worker es legítimo y en el hilo principal es un pecado.
- Manejar el ciclo de vida completo: creación, arranque en frío, terminación abrupta y agrupación.
Un agente, no una tarea
El vocabulario del estándar es más preciso que el coloquial y conviene adoptarlo. Un agente es la unidad de ejecución: tiene exactamente un hilo, una pila, un montículo de objetos y un bucle de eventos que va sacando tareas de una cola y las ejecuta hasta el final. Una ventana es un agente. Cada Worker que creas es otro agente, con todo lo anterior duplicado. Los agentes se agrupan en clústeres, y la regla que gobierna el clúster es la que da forma a este nivel entero: dentro de un clúster se puede compartir memoria; entre clústeres, nunca.
flowchart LR subgraph P[Agente del hilo principal] P1[Pila y memoria propias] P2[Bucle de eventos propio] P3[DOM y documento] end subgraph W[Agente del Worker] W1[Pila y memoria propias] W2[Bucle de eventos propio] W3[Sin DOM ni documento] end P2 -- postMessage --> W2 W2 -- postMessage --> P2 style P3 fill:#89b4fa,color:#11111b style W3 fill:#f38ba8,color:#11111b
La consecuencia inmediata es que un Worker no compite con tu interfaz por el mismo turno. Cuando un setTimeout o una promesa se resuelven, se encolan en tu bucle de eventos y esperan a que el turno actual termine; cuando un Worker calcula, lo hace en otro núcleo mientras tu bucle sigue repartiendo turnos con normalidad. No es paralelismo simulado por intercalado: es paralelismo físico, medible con dos relojes distintos y visible en cualquier monitor de sistema como dos hilos ocupados a la vez.
// hilo principal: crear el agente y hablarle
const w = new Worker(new URL('./calculo.worker.js', import.meta.url), {
type: 'module',
name: 'calculo',
});
w.postMessage({ n: 42 });
w.onmessage = ({ data }) => console.log('resultado', data);
w.onerror = (e) => console.error('el worker murio', e.message);
// calculo.worker.js: aqui no hay window; el objeto global es self
self.onmessage = ({ data }) => {
const t0 = performance.now();
const r = fib(data.n); // bloquear aqui no molesta a nadie
self.postMessage({ r, ms: performance.now() - t0 });
};
function fib(n) {
return n < 2 ? n : fib(n - 1) + fib(n - 2);
}
Dos detalles del constructor merecen atención. El primero es new URL con import.meta.url: es el patrón que los empaquetadores reconocen para producir un fichero independiente en lugar de intentar meter el worker en el paquete principal. El segundo es type: 'module', que cambia el dialecto del script: con módulos tienes import estático y dinámico pero pierdes importScripts, y el script se somete a las reglas habituales de resolución. La URL, además, debe ser del mismo origen; el rodeo clásico —crear un Blob con el código y pasar su URL— sigue funcionando y sigue siendo la vía por la que se cuelan workers generados en tiempo de ejecución.
El contexto global: lo que hay y lo que falta
Dentro de un Worker dedicado el objeto global no es Window sino DedicatedWorkerGlobalScope. La diferencia no es cosmética: es un catálogo distinto de APIs, y la amputación es deliberada. El DOM no está pensado para accesos concurrentes, así que en vez de blindarlo con cerrojos —el camino que tomaron los sistemas de interfaz de escritorio, con resultados conocidos— la plataforma decidió simplemente no exponerlo. No hay document, no hay window, no hay alert, y tampoco hay localStorage ni sessionStorage, precisamente porque son síncronos y su semántica depende de un hilo único.
// dentro del worker: inventario honesto
typeof self; // 'object'
typeof window; // 'undefined'
typeof document; // 'undefined'
typeof localStorage; // 'undefined' <- sincrono, prohibido aqui
typeof fetch; // 'function'
typeof indexedDB; // 'object'
typeof caches; // 'object'
typeof WebSocket; // 'function'
typeof WebAssembly; // 'object'
typeof crypto.subtle; // 'object'
typeof Atomics.wait; // 'function' <- y aqui SI se puede llamar
Todo el almacenamiento asíncrono
IndexedDB, Cache Storage y OPFS están disponibles íntegros. De hecho, OPFS solo ofrece su manejador de acceso síncrono dentro de un Worker, que es lo que hace posible SQLite sobre WebAssembly.
Toda la red
fetch, XMLHttpRequest, WebSocket, EventSource y las APIs de streams funcionan igual. Un Worker es un sitio excelente para orquestar sincronización sin tocar la interfaz.
Píxeles sin DOM
OffscreenCanvas te deja rasterizar, componer y hasta ejecutar WebGL fuera del hilo principal. No es el DOM: es un lienzo cuya propiedad se transfiere al Worker.
Nada de layout
No puedes medir un elemento, leer estilos calculados, tocar la historia de navegación ni pedir permisos que exijan un gesto del usuario. Todo eso vive donde vive el documento.
Hay una zona gris que conviene conocer antes de dar por segura la portabilidad. El objeto navigator existe dentro del Worker, pero es un WorkerNavigator: un subconjunto que conserva userAgent, hardwareConcurrency, storage o locks y descarta todo lo que dependa de una ventana visible. Y aunque los Workers de módulo llevan años estandarizados, la ergonomía real depende del empaquetador: si el tuyo no reconoce el patrón de new URL, acabarás sirviendo un fichero suelto sin transpilar o cayendo de vuelta a un Worker clásico con importScripts. Comprueba lo que emite tu compilación antes de dar por hecho que el Worker que escribiste es el Worker que se ejecuta.
Bloquear aquí es legítimo
Esta es la propiedad que más cambia la forma de escribir código y la que menos se aprovecha. En el hilo principal, un bucle de doscientos milisegundos es medio fotograma perdido, una animación rota y un teclado que no responde; por eso toda la cultura de la web moderna gira alrededor de no bloquear. Dentro de un Worker esa presión desaparece: nadie está pintando ahí, nadie espera un fotograma, y un bucle largo solo retrasa los mensajes de ese Worker.
El estándar lo reconoce explícitamente. Atomics.wait, que suspende el hilo hasta que otro agente le avise, lanza una excepción si lo llamas desde el hilo principal y funciona sin reservas dentro de un Worker. Lo mismo ocurre con el manejador de acceso síncrono de OPFS y con la petición síncrona de XMLHttpRequest. La plataforma no ha eliminado el bloqueo: lo ha confinado al único sitio donde es inofensivo.
Inofensivo no significa gratis, y la matización importa cuando el Worker atiende peticiones. Mientras tu bucle corre, el bucle de eventos de ese Worker está parado: los mensajes entrantes se acumulan en la cola, las devoluciones de llamada de IndexedDB esperan y cualquier temporizador que hubieras programado ahí dentro se retrasa. Un Worker que hace cómputo y además atiende consultas interactivas necesita las dos cosas separadas, normalmente en dos Workers distintos. La libertad de bloquear es real y es enorme, pero es libertad frente a la interfaz, no frente a tus propios clientes.
Escribir un recorrido recursivo, un parser o una compactación sin trocearlo artificialmente en fragmentos de cinco milisegundos es una liberación enorme. Toda la gimnasia de requestIdleCallback, generadores que ceden el turno y contadores de presupuesto existe para no congelar el pintado. Dentro de un Worker puedes escribir el algoritmo tal y como está en el libro, medirlo, y dejar que tarde lo que tarde.
Ciclo de vida, coste y variedades
Un Worker no es gratis. Crearlo implica levantar un contexto de ejecución nuevo, descargar y compilar el script y calentar de cero el compilador optimizador, que no comparte nada con el del hilo principal. En órdenes de magnitud: unos pocos milisegundos de arranque y del orden de algún megabyte de memoria base, antes de que tu código haya hecho nada. Por eso el patrón correcto casi nunca es un Worker por tarea, sino un conjunto reutilizado, dimensionado con navigator.hardwareConcurrency como techo orientativo y jamás como verdad.
// un conjunto reutilizado, no un worker por tarea
const nucleos = Math.min(navigator.hardwareConcurrency || 4, 4);
const libres = Array.from({ length: nucleos }, crearWorker);
const cola = [];
function encargar(tarea) {
return new Promise((resolve) => {
cola.push({ tarea, resolve });
repartir();
});
}
La terminación tiene una asimetría que sorprende la primera vez. self.close() desde dentro es ordenado: el Worker termina de procesar lo que tiene entre manos y se apaga. worker.terminate() desde fuera es brutal: el hilo se detiene donde esté, sin desenrollar la pila, sin ejecutar bloques finally, sin cerrar transacciones ni escribir lo pendiente.
Si tu Worker mantiene una transacción de IndexedDB abierta o un fichero de OPFS a medio escribir, terminate() lo corta en seco y puede dejar datos a medias. Y en el otro extremo del canal, todas las promesas pendientes se quedan colgadas para siempre porque nunca llegará su respuesta. Un cierre correcto es un protocolo: pides parar, el Worker confirma que ha cerrado lo suyo, y solo entonces terminas. Si no lo diseñas, el navegador te lo diseñará a su manera, que es sin diseño.
La familia tiene tres miembros y conviene no confundirlos. El dedicado pertenece a un solo documento y muere con él; es el que usarás por defecto. El compartido admite varias conexiones del mismo origen y sobrevive mientras quede alguna pestaña conectada, lo que lo convierte en el candidato natural a coordinador de datos —y en el tema del nivel siguiente—. El service worker no es un hilo de cómputo sino un proxy de red con ciclo de vida propio, que el navegador arranca y detiene cuando le conviene y en el que no debes guardar estado en memoria. Aparte quedan los worklets de audio y pintado, agentes minúsculos con reglas todavía más estrictas y presupuestos de tiempo medidos en microsegundos.
El error de encuadre más caro de este nivel es leer la ausencia del DOM como una carencia técnica que algún día se arreglará —una API pendiente, una limitación histórica, algo que los navegadores acabarán exponiendo cuando encuentren cómo—. No lo es, y entenderlo cambia todo lo que escribirás después. La ausencia del DOM es exactamente el mecanismo que hace que un Worker sea seguro, y por tanto que exista. Un árbol de documento es un grafo mutable, enorme, con invariantes que se rompen a mitad de cualquier modificación y con un motor de layout que asume que nadie más lo está tocando; exponerlo a dos hilos obligaría a proteger cada nodo con un cerrojo, y eso es precisamente el experimento que la industria de las interfaces de escritorio ya hizo durante veinte años, con un veredicto inequívoco y unánime: todos los sistemas de interfaz serios acabaron con un único hilo dueño de la pantalla, porque los cerrojos finos sobre un árbol de widgets producen bloqueos mutuos que nadie sabe reproducir. La web se saltó ese aprendizaje doloroso naciendo con la respuesta puesta. Fíjate entonces en la simetría perfecta que resulta: el hilo principal es dueño exclusivo de la pantalla y por eso no puede bloquear jamás; el Worker no ve la pantalla y por eso puede bloquear cuanto quiera. Cada uno renuncia a exactamente aquello que haría peligroso lo que el otro concede. No estás repartiendo trabajo entre dos hilos iguales, estás construyendo dos ciudadanos con derechos deliberadamente distintos, y el diseño de tu aplicación consiste en decidir qué responsabilidad pertenece a cuál. Todo lo que viene después de esta lección —el paso de mensajes, el clonado, los transferibles, el coste de la frontera— son consecuencias mecánicas de esta única decisión de reparto.
- Crea un Worker de módulo con
new URLeimport.meta.urly comprueba que tu empaquetador emite un fichero aparte en lugar de incluirlo en el paquete principal. - Ejecuta el inventario de
typeofde esta lección dentro del Worker y anota tres APIs que esperabas encontrar y no están. - Corre un bucle de un segundo en el Worker mientras una animación gira en la página. Repite el mismo bucle en el hilo principal y compara lo que ve el usuario.
- Mide el arranque en frío: tiempo desde
new Workerhasta el primer mensaje del Worker anunciando que está listo, con y sin caché. - Provoca un
terminate()en mitad de una escritura larga y observa qué queda a medias. Después diseña el protocolo de cierre ordenado que lo evita.