El clonado estructurado: qué cruza y qué se queda
El clonado estructurado copia el grafo de tu mensaje nodo a nodo, conserva ciclos e identidad, destruye prototipos y funciones, y cobra el recorrido dos veces, una en cada hilo.
Entre tu postMessage y el manejador del otro lado se interpone un algoritmo del estándar HTML con nombre poco glamuroso y consecuencias enormes: la serialización estructurada. No es un formato de intercambio como JSON ni una copia superficial como el operador de propagación: es un recorrido completo del grafo de objetos que sale de tu valor, con un mapa de nodos ya visitados para no caer en ciclos, un catálogo cerrado de tipos que sabe reconstruir y un rechazo tajante de todo lo que no encaje. Entenderlo con precisión resuelve de golpe las dos preguntas que más tiempo hacen perder en la frontera entre hilos: por qué mi objeto llegó sin métodos, y por qué enviar un mensaje tarda más que calcular la respuesta.
- Ver el algoritmo como lo que es: un copiador de grafos en memoria, no un serializador a texto.
- Memorizar el catálogo de lo que sobrevive, lo que lanza
DataCloneErrory lo que se degrada en silencio. - Entender que el coste se cobra dos veces y de forma síncrona, una en cada hilo.
- Diseñar la forma de los mensajes para que el recorrido sea barato en lugar de pelear con él.
Un copiador de grafos, no un serializador
La comparación con JSON.stringify aclara la naturaleza del algoritmo por contraste. JSON produce texto, se atraganta con un ciclo, no distingue una fecha de una cadena y convierte dos referencias al mismo objeto en dos objetos distintos. El clonado estructurado no produce texto en ningún momento —la representación intermedia es interna al motor y jamás la ves—, atraviesa ciclos sin inmutarse y, lo que más se subestima, preserva la identidad compartida: si tu mensaje contiene dos referencias al mismo objeto, al otro lado siguen siendo un único objeto con dos referencias.
const usuario = { nombre: 'Ada' };
const equipo = { lider: usuario, miembros: [usuario] };
equipo.miembros.push(equipo); // ciclo deliberado
const copia = structuredClone(equipo);
copia.lider === copia.miembros[0]; // true: la identidad se conserva
copia.miembros[1] === copia; // true: el ciclo tambien
copia.lider === usuario; // false: es otro objeto, claro
Que esas dos propiedades se conserven no es un detalle de lujo: es lo que permite enviar estructuras de datos reales —listas con nodos compartidos, índices que apuntan a los mismos registros, árboles con referencias al padre— sin aplanarlas primero. Y explica por qué el algoritmo tiene que llevar un mapa de nodos visitados, con el coste que eso implica.
flowchart LR A[Valor en el hilo emisor] --> B[Recorrido nodo a nodo] B --> C[Mapa de nodos ya vistos] C --> D[Representacion interna del motor] D --> E[Cola de mensajes del destino] E --> F[Reconstruccion nodo a nodo] F --> G[Grafo nuevo en el hilo receptor] style D fill:#cba6f7,color:#11111b style G fill:#a6e3a1,color:#11111b
El catálogo: pasa, muere o se degrada
El catálogo tiene tres cajones y el peligroso es el tercero, porque no avisa. En el primero está lo que atraviesa la frontera intacto: primitivas incluido BigInt y undefined, Date, RegExp, Map, Set, ArrayBuffer y todas las vistas tipadas, DataView, Blob, File, ImageData, ImageBitmap, los errores estándar del lenguaje y, por supuesto, arrays y objetos planos.
En el segundo está lo que lanza un DataCloneError en el acto, es decir, lo que falla ruidosamente y por tanto no da problemas: funciones, símbolos, nodos del DOM, WeakMap y WeakSet. Que fallen es una buena noticia; te enteras en la primera ejecución.
structuredClone(() => 1); // DataCloneError: las funciones no cruzan
structuredClone(Symbol('x')); // DataCloneError
structuredClone(new WeakMap()); // DataCloneError
structuredClone(document.body); // DataCloneError
El tercer cajón es el traicionero: lo que cruza pero llega distinto de como salió. Aquí no hay excepción, no hay aviso y el fallo aparece más tarde, en una línea que parecía inocente.
class Documento {
#interno = 'privado';
constructor(id) { this.id = id; }
get titulo() { return 'Doc ' + this.id; }
renombrar(t) { /* ... */ }
}
const d = new Documento(7);
Object.defineProperty(d, 'oculto', { value: 1, enumerable: false });
const c = structuredClone(d);
c instanceof Documento; // false: el prototipo se pierde
typeof c.renombrar; // 'undefined'
c.titulo; // undefined: el getter vivia en el prototipo
c.oculto; // undefined: lo no enumerable se descarta
Object.isFrozen(structuredClone(Object.freeze({}))); // false: se pierde el congelado
El prototipo no viaja
Toda instancia de clase llega como objeto plano. Métodos, getters heredados, campos privados e instanceof desaparecen. Si tu protocolo depende de clases, necesitas rehidratar a mano en el destino.
Solo lo propio y enumerable
Las propiedades no enumerables y las de clave simbólica se descartan sin ruido. Un accesor propio se invoca y se copia su resultado, convertido ya en dato inerte.
Los errores mienten un poco
Error y sus subclases estándar cruzan con nombre y mensaje, pero tu ErrorDeValidacion propio llega como un Error genérico. Serializa código y datos a mano si dependes de distinguirlos.
Lo que ya sabías de IndexedDB
Es exactamente el mismo algoritmo que usa put para escribir en disco. Lo que aprendiste en el nivel 8 sobre qué sobrevive a una transacción vale letra por letra en el canal entre hilos.
El precio se cobra dos veces
Aquí está la parte que casi nunca aparece en los tutoriales y que decide el rendimiento de una arquitectura con Workers. postMessage no es asíncrono para quien lo llama. La serialización ocurre de forma síncrona, en el hilo emisor, antes de que la función retorne. Después el mensaje viaja, y la reconstrucción ocurre también de forma síncrona, en el hilo receptor, antes de que se ejecute tu manejador. Un mensaje caro bloquea los dos hilos, uno detrás de otro, y ninguno de esos dos bloqueos aparece atribuido a tu código en un perfil ingenuo.
// aisla el coste real del recorrido, sin worker de por medio
const grafo = Array.from({ length: 100_000 }, (_, i) => ({
id: i,
nombre: 'registro ' + i,
etiquetas: ['alfa', 'beta'],
meta: { visto: false, version: 1 },
}));
let t = performance.now();
structuredClone(grafo);
console.log('grafo de objetos', (performance.now() - t).toFixed(1), 'ms');
// mismo volumen de bytes, un solo nodo
const plano = new Float64Array(600_000);
t = performance.now();
structuredClone(plano);
console.log('array tipado', (performance.now() - t).toFixed(1), 'ms');
La diferencia entre esas dos mediciones suele ser de dos órdenes de magnitud, y explica la regla operativa de toda la lección: el coste escala con el número de nodos del grafo, no con los bytes. Cien mil objetos pequeños son cien mil asignaciones, cien mil consultas al mapa de visitados y cien mil reconstrucciones al otro lado; un array tipado de varios megabytes es un nodo y una copia de bloque que el procesador hace a velocidad de memoria. Por eso una lista de registros pesa mucho más de lo que su tamaño en disco sugiere, y por eso reorganizar los mismos datos en columnas —un array tipado por campo en lugar de un objeto por fila— puede cambiar el coste de un mensaje de decenas de milisegundos a fracciones de uno.
Si en el hilo principal serializas un grafo de doscientos milisegundos para mandárselo a un Worker, has congelado la interfaz doscientos milisegundos, exactamente igual que si hubieras hecho el cálculo ahí mismo. La idea de haber movido el trabajo a otro hilo es entonces una ilusión: has movido el cálculo y te has quedado la copia. Mide siempre postMessage con el reloj alrededor de la propia llamada antes de convencerte de que has descargado el hilo principal.
Diseñar mensajes que no duelan
La conclusión no es evitar los Workers ni renunciar al clonado, sino tratar la forma del mensaje como una decisión de diseño con impacto medible, igual que tratas el esquema de una tabla. Cuatro criterios cubren casi todos los casos reales.
El primero es enviar datos, nunca objetos de dominio. Como los prototipos no cruzan, cualquier intento de mandar entidades ricas termina en objetos mutilados y en rehidrataciones frágiles. Define estructuras planas y explícitas para el canal, con un campo de tipo, y reconstruye lo que necesites en el destino. Es la misma disciplina que separa un DTO de una entidad, y aquí no es una preferencia estética: la impone el algoritmo.
El segundo es reducir el número de nodos. Mil registros con seis campos son siete mil nodos; los mismos datos en seis arrays paralelos son seis nodos más las primitivas. Si además esos arrays son tipados, entras en el terreno de la lección siguiente y el coste se desploma a cero transfiriendo en lugar de copiando.
El tercero es no mandar lo que no se va a leer. Es tentador enviar el registro completo porque ya lo tienes, pero cada campo ignorado se recorre, se copia y se reconstruye igual. Proyectar antes de enviar es la optimización más barata que existe en esta frontera.
El cuarto es medir antes de rediseñar. structuredClone está disponible como función global precisamente para que puedas aislar el coste del recorrido sin montar un Worker. Si el clonado de tu mensaje típico es de microsegundos, no reorganices nada; si es de decenas de milisegundos, ya sabes dónde está tu problema y no era el disco ni la red.
Vale la pena preguntarse por qué el catálogo es exactamente ese y no otro, porque la respuesta no es arbitraria ni histórica: es la única posible, y reconocerla te ahorra pelearte con el algoritmo durante años. Un objeto plano se puede copiar a otro agente porque su significado está enteramente contenido en él. Una función no, y no por una limitación de implementación —serializar el texto de una función es trivial— sino porque una función es un par formado por un código y un entorno léxico, y ese entorno encadena referencias a variables que viven en la memoria de este agente, memoria que por definición el otro no puede alcanzar. Copiar una clausura exigiría copiar el grafo entero al que alcanza, y ese grafo termina tocando el objeto global, el DOM, un socket abierto, algo intransportable. Lo mismo ocurre con el prototipo: no se pierde por descuido, se pierde porque un prototipo es comportamiento, y el comportamiento solo tiene sentido dentro del reino donde se definió. El algoritmo, por tanto, no está limitado: está trazando con exactitud la línea entre lo que es dato —forma pura, autocontenida, interpretable por cualquiera— y lo que es código —significado que depende de un contexto—. Y esa es exactamente la misma línea que separa el cuerpo de una petición HTTP de la lógica del servidor, la que hace que un CRDT deba ser una estructura de datos y no un objeto con métodos, la que obliga a que un esquema de base de datos describa formas y no operaciones, y la que hace que un fichero sobreviva cincuenta años mientras el programa que lo escribió no llega a cinco. Para una aplicación local-first el corolario es directo y bastante hermoso: si diseñas tus mensajes como datos puros que cruzan sin perder nada, has diseñado a la vez tu formato de persistencia y tu protocolo de sincronización, porque el clonado estructurado ya te obligó a la disciplina que esos dos exigen. El algoritmo parecía un peaje. Era un profesor.
- Ejecuta las dos mediciones de esta lección con tus datos reales y anota la relación entre nodos y milisegundos en tu máquina.
- Coge el mensaje más grande que envía tu aplicación y cuenta sus nodos. Reescríbelo en forma columnar y vuelve a medir.
- Manda una instancia de una clase con métodos y getters a un Worker y comprueba en el destino exactamente qué ha llegado y qué falta.
- Provoca un
DataCloneErrormetiendo una función dentro de un objeto anidado y observa qué dice el mensaje de error sobre dónde estaba. - Envuelve tu
postMessagemás pesado entre dos marcas deperformancey comprueba cuánto tiempo se pasa el emisor serializando antes de retornar.