Qué es IndexedDB realmente
IndexedDB es un motor transaccional de almacenes de objetos con índices secundarios y esquema versionado, no una base relacional: su modelo real, su orden total de claves y sus ausencias deliberadas.
Casi todo el mundo llega a IndexedDB buscando SQLite y encuentra otra cosa. No hay tablas, no hay columnas, no hay lenguaje de consulta y no existe nada que se parezca a un JOIN. Lo que hay es un motor de almacenamiento transaccional orientado a objetos, con índices secundarios y un esquema que solo puede modificarse durante una ceremonia de versión. Entender ese modelo —y no la API, que es fea y llega después— es lo que separa a quien pelea contra IndexedDB de quien lo usa como la base de datos embebida que en realidad es.
- Situar la jerarquía real: origen, base de datos, almacén de objetos, registro e índice.
- Entender qué puede ser una clave, qué puede ser un valor y por qué existe un orden total.
- Reconocer las ausencias deliberadas del modelo y qué implican para tu código.
- Leer la forma de la API asíncrona sin confundir su edad con su capacidad.
La jerarquía real
La unidad de aislamiento no es la pestaña ni la aplicación: es el origen. Esquema, host y puerto delimitan un espacio de almacenamiento propio, y dentro de él pueden convivir varias bases de datos identificadas por nombre. Cada base lleva además un número de versión, un entero positivo monotónico que describe la forma de su esquema.
Una base contiene almacenes de objetos, la pieza que la gente traduce mentalmente a “tabla” y que no lo es. Un almacén es un mapa ordenado de clave a valor: no impone forma a los registros, no declara columnas y no valida nada. Dos registros del mismo almacén pueden tener propiedades completamente distintas y a la base le da igual.
flowchart TD A[Un origen web] --> B[Base de datos con nombre y version] B --> C[Almacen de objetos usuarios] B --> D[Almacen de objetos documentos] C --> E[Registro con clave primaria y valor] C --> F[Indice secundario por email] F --> G[Mapa ordenado de clave de indice a clave primaria]
El índice es la única estructura que añade orden alternativo. No es una copia del almacén: es una proyección ordenada que asocia el valor de una propiedad con la clave primaria del registro. Consultar por índice siempre implica dos saltos lógicos —clave de índice, clave primaria, registro—, aunque la API te lo oculte.
Si un registro no tiene la propiedad que el índice proyecta, sencillamente no aparece en él. Es la razón por la que index.count() puede devolver menos que store.count() sobre el mismo almacén, sin que nada esté corrupto.
El origen como frontera tiene además una consecuencia moderna que conviene tener presente desde el primer día: los navegadores actuales particionan el almacenamiento por sitio de nivel superior, de modo que el mismo origen incrustado en dos sitios distintos ve dos bases de datos distintas. Y el conjunto completo de bases de un origen es inspeccionable desde el propio código.
// Que bases existen para este origen
const bases = await indexedDB.databases();
// [{ name: "cuaderno", version: 3 }, ...]
// Borrado completo, tambien negociado como un cambio de version
const borrado = indexedDB.deleteDatabase("cuaderno");
borrado.onblocked = () => console.warn("hay conexiones abiertas");
Claves, valores y el orden total
El conjunto de tipos admitidos como clave es cerrado y pequeño: números, cadenas, fechas, datos binarios y arrays de los anteriores. Quedan fuera los booleanos, null, undefined, NaN y cualquier objeto. Intentar usarlos lanza DataError en el acto.
Lo interesante es que la especificación define un orden total entre esos tipos, no solo dentro de cada uno: número, luego fecha, luego cadena, luego binario, luego array. Ese orden no es un detalle de implementación, es contrato: es lo que hace que un cursor recorra siempre la misma secuencia en cualquier navegador y lo que permite construir rangos de consulta predecibles.
Clave primaria
Determina la posición del registro en el almacén. Es única, inmutable mientras el registro exista y su tipo pertenece al conjunto cerrado de tipos de clave.
Valor
Cualquier cosa que sobreviva al algoritmo de clonado estructurado: objetos, arrays, Map, Set, Date, RegExp, Blob, File y buffers binarios.
Lo que no se clona
Funciones, nodos del DOM, símbolos y proxies revocados. Un objeto de clase pierde su prototipo: recuperas datos, nunca instancias.
Orden entre tipos
Números antes que fechas, fechas antes que cadenas, cadenas antes que binarios, binarios antes que arrays. Un contrato, no una casualidad.
La consecuencia práctica del clonado estructurado atrapa a mucha gente en frameworks reactivos: un objeto envuelto en un proxy de reactividad no siempre es clonable y provoca DataCloneError al guardarlo. La solución idiomática es desenvolverlo antes de escribir.
// El valor se clona, no se referencia
const registro = { id: 7, tags: new Set(["local", "first"]), creado: new Date() };
store.put(registro);
// Un objeto de clase entra, pero sale como objeto plano
class Nota { constructor(t) { this.t = t; } leer() { return this.t; } }
store.put(new Nota("hola"), 1);
// al recuperarlo: { t: "hola" } sin metodo leer
Las ausencias deliberadas
IndexedDB no tiene lenguaje de consulta. No hay agregación, no hay ordenación arbitraria, no hay expresiones de filtrado y no hay forma de relacionar dos almacenes en una sola operación. Todo lo que no sea “búsqueda por clave” o “búsqueda por rango sobre un índice” lo escribes tú en JavaScript, iterando.
Tampoco hay esquema en el sentido declarativo. El único esquema que existe es la lista de almacenes e índices que declaraste en la última migración; la forma de los registros no se valida jamás. Esta libertad es cómoda el primer día y cara el segundo: la coherencia de tus datos depende por completo de tu disciplina.
Relacionar documentos con sus autores exige leer los documentos, recolectar los identificadores y hacer una segunda ronda de lecturas. Dentro de una misma transacción es correcto y barato; entre transacciones distintas pierdes el aislamiento y puedes leer un estado que ya no existe.
La reunión manual entre almacenes es tan rutinaria que conviene verla escrita una vez. La clave está en que las dos rondas ocurran dentro del mismo alcance transaccional, porque solo así el conjunto de autores que lees corresponde exactamente al estado en el que leíste los documentos.
const tx = db.transaction(["documentos", "autores"], "readonly");
const docs = tx.objectStore("documentos");
const autores = tx.objectStore("autores");
const listado = docs.index("por_libreta").getAll("trabajo");
listado.onsuccess = () => {
// Segunda ronda: una lectura por identificador, misma transaccion
for (const doc of listado.result) {
const a = autores.get(doc.autorId);
a.onsuccess = () => enlazar(doc, a.result);
}
};
La forma de la API
La API nació en 2010, antes de las promesas, y se nota: cada operación devuelve un objeto de petición al que se le adjuntan manejadores de evento. No es una limitación del motor, es un envoltorio anticuado sobre un motor perfectamente capaz.
const peticion = indexedDB.open("cuaderno", 1);
peticion.onupgradeneeded = (evento) => {
const db = evento.target.result;
const notas = db.createObjectStore("notas", { keyPath: "id" });
notas.createIndex("por_fecha", "creado");
};
peticion.onsuccess = (evento) => {
const db = evento.target.result;
const tx = db.transaction("notas", "readonly");
const lectura = tx.objectStore("notas").get(7);
lectura.onsuccess = () => console.log(lectura.result);
};
peticion.onerror = () => console.error(peticion.error);
Ese estilo por eventos no es decorativo: es el mecanismo que mantiene viva una transacción, como verás en la tercera lección. Envolver la API en promesas sin entender esa relación es la causa número uno de transacciones que mueren a mitad de camino.
La reputación de IndexedDB —lenta, hostil, arcaica— confunde dos cosas que conviene separar con cuidado, porque de esa confusión salen casi todas las decisiones malas de arquitectura local-first. Por debajo hay un motor transaccional serio: almacenes ordenados por clave, índices secundarios con orden total garantizado, aislamiento real entre transacciones y durabilidad frente al cierre del navegador; en Chromium ese motor es LevelDB, en Firefox y Safari es SQLite, y en los tres casos estás hablando con una base de datos embebida de verdad, no con un mapa persistido. Por encima hay una interfaz diseñada antes de que existieran las promesas, que expone peticiones con manejadores de evento, que obliga a declarar el alcance de la transacción por adelantado y que solo deja tocar el esquema dentro de un evento de actualización. Esa interfaz es la que duele, y es la que envuelves con idb o con Dexie sin perder ni una sola de las garantías de abajo. La confusión importa porque lleva a la decisión equivocada: quien cree que el problema es el motor huye a localStorage —que es síncrono, bloquea el hilo principal, guarda solo cadenas y tiene un techo de cinco megabytes— o monta una capa en memoria que sincroniza a mano y reinventa mal la durabilidad. Quien entiende que el problema es la ergonomía elige el envoltorio, aprende el modelo de clave, valor, índice y transacción, y descubre que ya tenía debajo la única base de datos transaccional que todos los navegadores traen de serie, sin instalar nada, sin pedir permisos y sin depender de WebAssembly. El modelo es el activo; la API es un accidente histórico que se corrige con veinte líneas de envoltorio.
- Abre una base con dos almacenes y guarda en el mismo almacén tres registros con formas distintas: confirma que ninguno es rechazado.
- Intenta guardar un valor con una función dentro y observa el
DataCloneError; repite con unMapy comprueba que sobrevive. - Guarda claves de tipo número, fecha y cadena en un almacén sin
keyPathy recórrelo con un cursor: verifica el orden total de la especificación. - Crea un índice sobre una propiedad que solo tenga la mitad de tus registros y compara
store.count()conindex.count().