wandres.dev
WEB WORKERS · Sacar el trabajo del hilo

El coste de postMessage y la serialización estructurada

Cómo funciona el algoritmo de clonado estructurado, qué formas de datos son baratas y cuáles caras, cómo medir el coste real de tu carga útil, y las tres estrategias para reducirlo.

⏱ 18 min

Mover trabajo a otro hilo solo compensa si mover los datos cuesta menos que el trabajo. postMessage no pasa una referencia: copia, mediante un algoritmo llamado clonado estructurado que recorre el grafo de objetos y lo reconstruye en el otro lado. Ese recorrido es trabajo del hilo principal, ocurre de forma síncrona antes de que el mensaje salga, y para ciertas formas de datos es más caro que el cálculo que querías externalizar.

🎯 Al terminar esta lección sabrás
  • Describir qué hace el algoritmo de clonado estructurado y qué tipos admite.
  • Distinguir las formas de datos baratas de las caras y explicar por qué.
  • Medir el coste de serialización de tu carga útil con un banco reproducible.
  • Aplicar las tres estrategias de reducción del coste de mensajes.

Qué hace el clonado estructurado

El algoritmo recorre el objeto que le pasas y construye una copia independiente en el destino. No es JSON.stringify: es más capaz y funciona de forma distinta.

Tipos que admite y JSON no: Map, Set, Date, RegExp, ArrayBuffer y todos los arrays de tipos, Blob, File, ImageData, BigInt, y referencias cíclicas, que resuelve correctamente en lugar de lanzar una excepción.

Tipos que no admite: funciones, símbolos, nodos del DOM, y objetos con prototipos personalizados, cuyos métodos se pierden. Una instancia de una clase llega al otro lado como un objeto plano con sus propiedades y sin su cadena de prototipos. Intentar clonar una función lanza DataCloneError.

El algoritmo está disponible directamente como structuredClone(), lo cual permite medirlo aislado sin montar un trabajador:

const copia = structuredClone(original);

Y el coste es doble: se serializa en el origen y se deserializa en el destino. Si mides solo structuredClone en el hilo principal, estás midiendo aproximadamente la mitad del coste total de un mensaje.

Qué es barato y qué es caro

La diferencia entre formas de datos es de uno o dos órdenes de magnitud, y viene de cuántas asignaciones de memoria y cuántas comprobaciones de tipo hace el algoritmo.

Muy barato: los buffers y los arrays de tipos. Un Float64Array de un millón de elementos es un bloque de memoria contiguo: copiarlo es esencialmente un memcpy. Y si lo transfieres en lugar de copiarlo, el coste es constante e independiente del tamaño.

Barato: arrays de números o de cadenas. Homogéneos, sin estructura anidada.

Caro: arrays de objetos pequeños. Diez mil objetos con ocho propiedades cada uno son ochenta mil asignaciones de propiedad, cada una con su comprobación de tipo. Es la forma de datos más común en aplicaciones —una respuesta de API típica— y una de las peores para clonar.

Muy caro: estructuras profundamente anidadas con muchas referencias. El algoritmo tiene que llevar un registro de los objetos ya visitados para resolver los ciclos, y ese registro se consulta en cada nodo.

El banco que da los números de tu caso concreto:

function medirClonado(nombre, construir, repeticiones = 20) {
  const dato = construir();
  structuredClone(dato);                        // calentar
  const t0 = performance.now();
  for (let i = 0; i < repeticiones; i++) structuredClone(dato);
  const ms = (performance.now() - t0) / repeticiones;
  const bytes = new Blob([JSON.stringify(dato)]).size;   // aproximacion del volumen
  console.log(`${nombre.padEnd(30)} ${ms.toFixed(2)} ms  (~${(bytes / 1024).toFixed(0)} KB)`);
}

const N = 50_000;
medirClonado('Float64Array 50k', () => new Float64Array(N).map((_, i) => i * 1.5));
medirClonado('array de numeros 50k', () => Array.from({ length: N }, (_, i) => i * 1.5));
medirClonado('array de objetos 50k x 8', () => Array.from({ length: N }, (_, i) => ({
  id: i, nombre: `n${i}`, total: i * 1.5, activo: i % 2 === 0,
  fecha: Date.now(), tipo: 'x', grupo: i % 10, nota: null,
})));
medirClonado('Map de 50k entradas', () => new Map(Array.from({ length: N }, (_, i) => [i, { v: i }])));

Los resultados varían mucho por dispositivo, y lo que importa es la relación entre las filas, que suele ser de dos órdenes de magnitud entre la primera y la tercera. Ejecútalo en tu dispositivo de referencia antes de diseñar la comunicación con el trabajador.

Y la comparación que decide: el coste de clonar frente al coste del cálculo. Si ordenar cincuenta mil objetos cuesta 120 milisegundos y clonarlos cuesta 180 de ida más 180 de vuelta, el trabajador ha convertido 120 milisegundos de bloqueo en 360. Has empeorado la situación.

Las tres estrategias

Estrategia uno: cambiar la forma de los datos. La más eficaz con diferencia. Un array de objetos se convierte en varios arrays de tipos, uno por columna. Es la disposición de estructura de arrays, y además de clonarse cien veces más rápido, se transfiere sin copia.

// Antes: array de objetos, caro de clonar
const filas = [{ id: 1, total: 12.5, fecha: 1735689600000 }, /* ... */];

// Despues: columnas en arrays de tipos, transferibles
const columnas = {
  id: new Int32Array(n),
  total: new Float64Array(n),
  fecha: new Float64Array(n),
};
// Se transfiere sin copiar: coste constante
trabajador.postMessage(columnas, [columnas.id.buffer, columnas.total.buffer, columnas.fecha.buffer]);

La transformación tiene un coste propio —hay que construir las columnas— pero se hace una vez, en el sitio donde entran los datos, y a partir de ahí todos los viajes son gratis. Los detalles de la transferencia están en transferibles.

Estrategia dos: mover menos datos. A menudo el trabajador no necesita los objetos completos. Si va a ordenar por un campo y devolver el orden, mándale solo ese campo y que devuelva un array de índices:

// En lugar de mandar 50.000 objetos y recibir 50.000 objetos
const clave = new Float64Array(filas.map((f) => f.total));
const orden = await trabajador.ordenar(clave);      // devuelve Int32Array de indices
const ordenadas = Array.from(orden, (i) => filas[i]);  // reordenar en el hilo, es barato

El hilo principal hace un trabajo trivial —indexar un array— y el pesado ocurre fuera con una carga útil mínima.

Estrategia tres: no traer los datos de vuelta. Si el resultado se va a pintar en un canvas, transfiere el canvas al trabajador y que dibuje él. Si el resultado va a IndexedDB, que el trabajador escriba directamente, porque IndexedDB está disponible ahí. El mejor mensaje es el que no existe.

El coste de postMessage no está solo en la serialización: la cola de mensajes puede convertirse en el cuello de botella

Hay una segunda dimensión del coste que no aparece en ningún banco de serialización y que produce problemas muy difíciles de diagnosticar: el manejador onmessage se ejecuta como una tarea en el hilo receptor, y muchos mensajes seguidos son muchas tareas.

El caso concreto. Un trabajador procesa un flujo y envía actualizaciones de progreso. Con un mensaje por elemento y diez mil elementos, el hilo principal recibe diez mil tareas. Cada una es corta, ninguna es una tarea larga, y el TBT sale impecable. Y la página está congelada, porque el hilo principal no hace otra cosa que vaciar la cola de mensajes. Es exactamente el perfil de peine que ya sabes reconocer, y el causante es el trabajador que supuestamente iba a descargar el hilo.

Tres reglas que lo evitan.

Uno: agrupa los mensajes de progreso. Emite como mucho uno cada 100 o 200 milisegundos, con el estado acumulado, no uno por elemento:

// dentro del worker
let ultimoAviso = 0;
for (let i = 0; i < total; i++) {
  procesar(i);
  const ahora = performance.now();
  if (ahora - ultimoAviso > 150) {
    self.postMessage({ tipo: 'progreso', hechos: i, total });
    ultimoAviso = ahora;
  }
}
self.postMessage({ tipo: 'fin', total });

Dos: envía resultados en lotes, no en goteo. Un mensaje con mil resultados cuesta mucho menos que mil mensajes con uno.

Tres, y es la que resuelve el caso general: usa un flujo en lugar de mensajes. Los flujos legibles son transferibles, y permiten al receptor consumir a su ritmo con contrapresión, en lugar de recibir todo lo que el emisor produzca:

// worker: producir un flujo y transferirlo
const flujo = new ReadableStream({
  async pull(controlador) {
    const lote = await siguienteLote();
    if (!lote) controlador.close();
    else controlador.enqueue(lote);
  },
});
self.postMessage({ flujo }, [flujo]);
// hilo principal: consumir cuando pueda, sin inundarse
trabajador.addEventListener('message', async (e) => {
  for await (const lote of e.data.flujo) {
    renderizarLote(lote);
    await ceder();                 // consume a su ritmo, cediendo entre lotes
  }
});

La contrapresión es la propiedad clave: el trabajador no produce más rápido de lo que el hilo principal consume, y la cola de mensajes deja de crecer. Es la solución arquitectónicamente correcta para cualquier trabajador que produzca un volumen grande de resultados, y evita además el problema de memoria de acumular en una cola de mensajes miles de cargas útiles pendientes de procesar.

Un apunte sobre disponibilidad: la transferencia de flujos entre contextos está en los tres motores desde hace ya varias versiones, y aun así conviene la comprobación, porque el fallo se manifiesta como un DataCloneError en tiempo de ejecución y no en compilación.

La regla de decisión

Antes de mover algo a un trabajador, haz esta cuenta con números medidos, no estimados:

Coste actual = tiempo de cálculo en el hilo principal.

Coste con trabajador = serializar entrada + deserializar entrada + cálculo + serializar salida + deserializar salida, donde las partes de serialización ocurren en el hilo que corresponda.

Lo que importa no es el total sino el reparto: cuánto de ese coste cae en el hilo principal. Si la entrada y la salida son transferibles, la parte del hilo principal es casi cero y la ganancia es total. Si son arrays de objetos grandes, puede que la mayor parte del coste siga en el hilo principal y el trabajador no aporte nada.

⚔️ Reto práctico

Ejecuta el banco de clonado en tu dispositivo de referencia con la forma de datos real de tu aplicación. Compara el coste de clonar con el coste del cálculo que querías mover. Si la relación es desfavorable, convierte la carga útil a estructura de columnas con arrays de tipos y vuelve a medir. Anota cuánto baja: en datos numéricos tabulares, el factor suele estar entre 50 y 200.