Clonado estructurado: el peaje de cada entrada y cada salida
Cada valor que cruza la frontera de IndexedDB se serializa y se copia con el algoritmo de clonado estructurado, un recorrido nodo a nodo que domina el coste real cuando los objetos crecen.
IndexedDB no guarda tus objetos. Guarda una imagen serializada de tu grafo de objetos y te entrega una reconstrucción nueva cada vez que lees. Entre tu variable y el disco vive un algoritmo del estándar HTML —el clonado estructurado— que recorre el grafo nodo a nodo, detecta ciclos, rechaza lo que no sabe representar y produce una copia profunda. Ese recorrido, y no la escritura en el disco, es lo que suele dominar el perfil en cuanto los objetos dejan de ser diminutos.
- Entender que
putygetno comparten memoria: serializan y deserializan un grafo completo. - Conocer qué tipos sobreviven al clonado, cuáles lanzan
DataCloneErrory cuáles pierden su identidad. - Razonar el coste como función del número de nodos del grafo, no de su tamaño en bytes.
- Medirlo aislado con
structuredCloneantes de acusar al disco.
Qué ocurre al cruzar la frontera
La especificación HTML define dos operaciones abstractas complementarias: StructuredSerialize, que convierte un valor de JavaScript en una representación intermedia independiente del realm, y StructuredDeserialize, que reconstruye un valor nuevo a partir de ella. IndexedDB invoca la primera —en su variante ForStorage, algo más estricta— sobre cada valor que pasas a put o add, y la segunda sobre cada valor que sale de un get, un getAll o un cursor.
Tres consecuencias caen de ahí y conviene tenerlas explícitas. La primera: el algoritmo es síncrono y se ejecuta en el hilo que hizo la llamada, aunque la API sea asíncrona; lo asíncrono es la entrada y salida, no el recorrido. La segunda: es una copia profunda, de modo que el objeto que lees nunca es idéntico al que escribiste, ni siquiera si la escritura acaba de ocurrir. La tercera: el algoritmo mantiene un mapa de memoria de los objetos ya visitados para preservar la identidad compartida y tolerar ciclos, lo que significa que cada nodo del grafo paga una consulta y una inserción en una tabla hash.
flowchart LR A[objeto en tu hilo] --> B[StructuredSerializeForStorage] B --> C[representacion intermedia] C --> D[motor de almacenamiento] D --> E[disco] E --> F[bytes recuperados] F --> G[StructuredDeserialize] G --> H[objeto NUEVO distinto del original]
Que la lectura devuelva un objeto nuevo no es un detalle académico: invalida cualquier estrategia de caché basada en identidad referencial, obliga a revalidar invariantes y explica por qué mutar lo que acabas de leer no cambia nada en la base.
Conviene además distinguir dos algoritmos que la API mezcla sin avisar. Los valores pasan por el clonado estructurado en su variante para almacenamiento, algo más restrictiva que la general —no admite objetos que solo tienen sentido dentro de un realm vivo, ni memoria compartida—. Las claves, en cambio, no se clonan: pasan por una conversión mucho más estrecha que solo acepta números finitos, cadenas, fechas, ArrayBuffer y sus vistas, y arrays de esos mismos tipos. Nada más. Un undefined, un null, un booleano o un objeto plano como clave lanzan DataError, y una ruta de clave que apunte a un campo ausente simplemente no produce entrada de índice.
store.put({ id: 1, activo: true }); // ok: la clave es 1
store.put({ id: null }); // DataError: null no es clave
store.put({ id: [2026, "abril"] }); // ok: array de clave valida
store.put({ id: { compuesta: true } }); // DataError: objeto no es clave
Que las claves usen un conjunto de tipos mucho más pobre que los valores tiene una consecuencia de diseño que sorprende a quien viene de otras bases: no puedes indexar por booleano. Un campo activo: true no es indexable, y el rodeo habitual es guardarlo como número o como cadena solo para poder consultarlo. Distinguir DataError —clave inválida— de DataCloneError —valor no serializable— acorta muchísimo el diagnóstico, porque señalan a lados opuestos del registro.
El catálogo: qué sobrevive y qué no
Se clona con fidelidad
Primitivas, String, Number, Boolean, Date, RegExp, Map, Set, Error y sus subtipos estándar, ArrayBuffer, arrays tipados, DataView, Blob, File, FileList, ImageData y ImageBitmap. También arrays y objetos planos anidados a cualquier profundidad, con ciclos y con aliasing preservado.
Lanza DataCloneError
Funciones y métodos, símbolos, WeakMap, WeakSet, proxies y los nodos del DOM. La excepción se dispara de forma síncrona dentro de la llamada a put, no en el evento de error de la petición, así que un try mal colocado se la come sin que te enteres.
Sobrevive, pero degradado
Las instancias de clase pierden su prototipo y vuelven como objetos planos; los campos privados desaparecen; los getters se evalúan y se guarda el valor resultante, no la función; las propiedades no enumerables y los descriptores se pierden. Lo que recuperas tiene la forma, no el comportamiento.
El caso barato
Un ArrayBuffer es una secuencia opaca de bytes: un solo nodo, sin recorrido interno. Un Blob es aún mejor, porque los motores lo tratan como una referencia a almacenamiento externo. Diez megabytes de bytes opacos cuestan mucho menos que diez mil objetos pequeños.
class Nota {
#secreto = 42;
constructor(t) { this.texto = t; this.creada = new Date(); }
get resumen() { return this.texto.slice(0, 20); }
render() { return `<p>${this.texto}</p>`; }
}
const copia = structuredClone(new Nota("hola"));
copia instanceof Nota; // false: el prototipo se perdio
copia.creada instanceof Date; // true: Date si esta en el catalogo
copia.resumen; // "hola": el getter se evaluo y quedo como valor
copia.render; // undefined: el metodo vivia en el prototipo
store.put(valor) serializa en el acto, antes de devolver el IDBRequest. Si el valor contiene una función o un nodo del DOM, la excepción se propaga por la pila de llamadas normal y no por request.onerror. Un objeto de dominio con un callback colgado —un onChange, un dispose— es la causa más frecuente de un DataCloneError que aparece meses después de escribir el código, el día que alguien añade ese campo.
El coste escala con los nodos, no con los bytes
Aquí está el modelo mental que hay que interiorizar. El clonado estructurado es un recorrido en profundidad del grafo. Su coste es aproximadamente lineal en el número de nodos visitados, no en la cantidad de memoria que ocupan. Por cada nodo hay que decidir su tipo, consultar el mapa de memoria para saber si ya se visitó, insertarlo, iterar sus claves propias enumerables y recursar. Ese trabajo por nodo es pequeño pero constante, y se multiplica por la cardinalidad del grafo.
De ahí sale una asimetría poco intuitiva: un registro con un ArrayBuffer de cinco megabytes se serializa casi instantáneamente, mientras que un registro de cincuenta kilobytes formado por veinte mil objetos minúsculos —una lista de operaciones, un árbol sintáctico, un documento con anotaciones por carácter— puede costar órdenes de magnitud más. La factura no la emite el disco: la emite el recorrido.
Esa asimetría es especialmente cruel en local-first, porque las estructuras que este tipo de aplicaciones acumulan son exactamente las peores para el algoritmo: historiales de operaciones, árboles de documento con un nodo por fragmento de texto, mapas de metadatos por identificador de réplica. Son grafos con altísima cardinalidad y poquísimo contenido por nodo, es decir, la peor relación posible entre trabajo de recorrido y bytes útiles. Un CRDT maduro guarda su estado en representaciones compactas y binarias precisamente por esta razón, aunque su modelo lógico sea un grafo.
// Aisla el peaje del resto del sistema: sin transacciones, sin disco, sin IPC.
function medirClonado(valor, repeticiones = 20) {
structuredClone(valor); // calienta el JIT
const t0 = performance.now();
for (let i = 0; i < repeticiones; i++) structuredClone(valor);
return (performance.now() - t0) / repeticiones;
}
// Compara dos representaciones del MISMO contenido.
const grafo = { ops: Array.from({ length: 20000 }, (_, i) => ({ i, t: "ins", c: "x" })) };
const bytes = new TextEncoder().encode(JSON.stringify(grafo)).buffer;
medirClonado(grafo); // recorre 20001 nodos y sus propiedades
medirClonado(bytes); // recorre 1 nodo opaco
El global structuredClone ejecuta serialización y deserialización seguidas, sin tocar disco ni cruzar procesos. Si el tiempo que mide ahí ya es del orden del que ves en tu transacción, has terminado el diagnóstico: el cuello de botella es la forma de tus datos y ninguna optimización de transacciones, índices o durabilidad lo va a mover. Es la primera medición que hay que hacer y casi nadie la hace.
Del diagnóstico al diseño
Antes de rediseñar nada conviene tener una cifra de referencia: cuántos nodos tiene de verdad tu registro típico. Casi nadie lo sabe, y casi siempre la respuesta es un orden de magnitud mayor de lo que se estimaba, porque el anidamiento se acumula por capas —un envoltorio aquí, unos metadatos allá— sin que ninguna sea culpable por sí sola.
function contarNodos(v, vistos = new Set()) {
if (v === null || typeof v !== "object" || vistos.has(v)) return 0;
vistos.add(v);
let n = 1;
for (const k of Object.keys(v)) n += contarNodos(v[k], vistos);
return n;
}
La consecuencia práctica es que la forma del registro es una decisión de rendimiento, no de estilo. Aplanar un grafo profundo en unos pocos objetos con campos escalares reduce la cardinalidad y por tanto el peaje. Sustituir la parte que nunca se consulta por un ArrayBuffer codificado con tu propio formato —o con un códec binario compacto— convierte miles de nodos en uno solo, a cambio de perder la capacidad de indexar por dentro. Y mantener fuera del registro los campos que solo existen para la vista evita pagar por lo que se recalcula igual al leer.
// Antes: un grafo entero por registro, indexable pero caro de cruzar.
await store.put({ id, titulo, autor, bloques: [ /* miles de nodos */ ] });
// Despues: escalares para indexar arriba, carga util opaca abajo.
await store.put({ id, titulo, autorId: autor.id, cuerpo: codificar(bloques) });
Existe un escalón intermedio entre el grafo y el buffer binario que casi nadie considera: guardar el cuerpo como una cadena de texto ya serializada. Una cadena es un solo nodo para el clonado, igual que un buffer, y no exige elegir códec ni escribir decodificador; a cambio, pagas una conversión a texto al escribir y un análisis sintáctico al leer. Si eso sale a cuenta o no depende del motor y de la forma del dato, y es exactamente el tipo de decisión que hay que resolver midiendo las tres variantes sobre tus registros reales en lugar de razonándola.
Hay un matiz que se pasa por alto y que puede invertir el resultado: el clonado no solo cuesta tiempo, también cuesta memoria transitoria. Durante la serialización coexisten el grafo original y su representación intermedia; durante la deserialización coexisten los bytes y el grafo reconstruido. Con registros grandes eso significa un pico de asignación que el recolector tendrá que limpiar después, y ese trabajo aparece más tarde, desplazado, en un punto del perfil donde ya no lo relacionas con la escritura que lo causó. Los picos de memoria por lectura masiva y las pausas de recolección poco después son el mismo fenómeno visto dos veces.
La ilusión que sostiene todo el malentendido con IndexedDB es que se parece a un mapa: metes un objeto, sacas un objeto. Pero entre esas dos operaciones hay una frontera de aislamiento tan real como la de un proceso, y cruzarla no es guardar, es marshalling: traducir un grafo vivo, lleno de prototipos, closures e identidad referencial, a una representación plana, autocontenida y sin comportamiento, que pueda sobrevivir sin el realm que la creó. Ese es exactamente el problema que resolvieron CORBA, Protocol Buffers y cada RPC de la historia, y por eso comparte sus mismas leyes: el coste es proporcional a la complejidad estructural, los tipos ricos no atraviesan, y la fidelidad se paga en tiempo. Cuando dejas de ver put como una asignación y empiezas a verlo como una llamada remota cuyo argumento hay que empaquetar entero, se reordenan solas todas las decisiones que vienen después. Almacenar bytes en lugar de objetos deja de ser un truco sucio y pasa a ser lo que siempre fue: elegir tú el códec en vez de aceptar el que impone el navegador. Agrupar escrituras deja de ser una micro-optimización y pasa a ser amortizar el coste de una llamada. Y las estructuras profundas —árboles de documento, historiales de operaciones, precisamente lo que un sistema local-first acumula— dejan de ser datos y pasan a ser lo que de verdad son al otro lado de esa frontera: mensajes que hay que serializar, uno a uno, cada vez.
- Coge el registro más grande que escriba tu aplicación y cuenta sus nodos con un recorrido recursivo. Anota la cifra.
- Mide ese registro con
medirClonadoy compáralo con el tiempo total de una transacción que solo lo escriba. Calcula qué fracción del total es el clonado. - Codifica el mismo contenido como un
ArrayBuffery repite la medición. Comprueba en qué factor cae. - Intenta guardar un objeto con un método y captura el
DataCloneErroren el sitio correcto: en eltryalrededor delput, no en elonerror. - Escribe un objeto con una referencia cíclica, léelo de vuelta y verifica que el ciclo sigue ahí y que la identidad compartida se preservó.