wandres.dev
APPLICATION · Almacenamiento y manifiesto

IndexedDB en el inspector: bases, almacenes, índices y versiones

Recorrer una base de datos desde el panel, entender el modelo de almacenes e índices, depurar el evento de cambio de versión, y las tres causas de una transacción que falla en silencio.

⏱ 18 min

IndexedDB es el almacén serio del navegador: asíncrono, transaccional, con índices, capaz de guardar objetos estructurados y binarios, disponible en workers y con cuota de cientos de megabytes. También tiene una API áspera basada en peticiones con devoluciones de llamada y un modelo de versiones que sorprende la primera vez. El inspector del panel de aplicación quita buena parte de esa aspereza al hacer visible lo que hay dentro, que es justamente lo que su API no facilita.

🎯 Al terminar esta lección sabrás
  • Recorrer bases, almacenes e índices desde el panel y editar registros.
  • Explicar el modelo de versiones y qué ocurre en el evento de actualización.
  • Diagnosticar una transacción que no completa y sus tres causas habituales.
  • Consultar la base desde la consola sin escribir devoluciones de llamada.

El inspector

En la sección de almacenamiento aparecen todas las bases del origen. Cada una se despliega en sus almacenes de objetos, y cada almacén en sus índices.

Al seleccionar un almacén, la tabla muestra los registros con su clave y su valor, paginados. El valor se puede expandir como un objeto, lo que permite inspeccionar estructuras anidadas sin escribir código. Y hay tres acciones directamente útiles: borrar un registro, vaciar un almacén y eliminar la base entera.

El botón de refrescar es más importante de lo que parece: la vista no se actualiza sola. Si tu código escribe mientras miras, sigues viendo la foto anterior, y eso ha producido muchas conclusiones falsas del tipo “no está escribiendo nada”.

Al seleccionar un índice, la tabla se ordena por la clave de ese índice en lugar de por la primaria. Es la forma más rápida de comprobar si un índice está bien definido: si sus claves no son lo que esperabas, la consulta que dependa de él no va a devolver lo que crees.

⚠️
Cuidado

Eliminar la base desde el panel mientras la aplicación la tiene abierta puede dejar la conexión en un estado extraño. El orden correcto es cerrar la pestaña o recargar después de eliminar. Y si la aplicación no maneja el evento de bloqueo, la eliminación puede quedarse esperando indefinidamente sin decir nada, que es uno de los comportamientos más desconcertantes de esta API.

El modelo, en cinco conceptos

Base de datos. Identificada por nombre y con un número de versión entero. Pertenece a un origen.

Almacén de objetos. El equivalente a una tabla. Guarda valores con una clave. La clave puede venir de una propiedad del propio valor, generarse automáticamente, o pasarse aparte.

Índice. Una vista ordenada por otra propiedad del valor, que permite buscar por algo que no es la clave primaria. Puede ser único o no, y puede ser multi-entrada cuando la propiedad es un array.

Transacción. Toda lectura y toda escritura ocurren dentro de una. Tiene un modo, de solo lectura o de lectura y escritura, y una lista de almacenes a los que puede acceder.

Evento de actualización de versión. El único momento en que se puede cambiar la estructura: crear almacenes, crear índices, borrarlos. Se dispara al abrir la base con un número de versión mayor que el guardado.

Ese último concepto es el que más problemas da porque su modelo mental es distinto del de una migración de base de datos normal. La estructura solo se puede modificar dentro de ese evento, y el evento solo ocurre al subir la versión. Si te olvidas de crear un índice y lo añades al código sin subir el número de versión, el índice no existirá para nadie que ya tuviera la base creada, y funcionará perfectamente para ti si borraste la base mientras desarrollabas.

Una capa mínima usable

La API basada en peticiones se maneja mucho mejor envuelta en promesas. Este envoltorio es corto, completo y ejecutable.

// Capa minima sobre IndexedDB con promesas y migraciones por version
function abrirBase(nombre, version, migraciones) {
  return new Promise((resolver, rechazar) => {
    const peticion = indexedDB.open(nombre, version);

    peticion.onupgradeneeded = (e) => {
      const db = peticion.result;
      const desde = e.oldVersion;
      console.log('Migrando de la version', desde, 'a la', e.newVersion);
      for (let v = desde + 1; v <= e.newVersion; v++) {
        migraciones[v]?.(db, peticion.transaction);
      }
    };

    peticion.onsuccess = () => {
      const db = peticion.result;
      // Si otra pestaña sube la version, hay que cerrar esta conexion
      db.onversionchange = () => {
        db.close();
        console.warn('La base se actualizo en otra pestaña. Recarga.');
      };
      resolver(db);
    };
    peticion.onerror = () => rechazar(peticion.error);
    peticion.onblocked = () => console.warn('Otra pestaña tiene la base abierta con version antigua.');
  });
}

function transaccion(db, almacenes, modo, trabajo) {
  return new Promise((resolver, rechazar) => {
    const tx = db.transaction(almacenes, modo);
    let resultado;
    tx.oncomplete = () => resolver(resultado);
    tx.onerror = () => rechazar(tx.error);
    tx.onabort = () => rechazar(tx.error || new Error('Transaccion abortada'));
    resultado = trabajo(tx);
  });
}

const promesa = (peticion) => new Promise((res, rec) => {
  peticion.onsuccess = () => res(peticion.result);
  peticion.onerror = () => rec(peticion.error);
});

// Uso completo y ejecutable
const db = await abrirBase('demo-devtools', 2, {
  1: (db) => {
    const s = db.createObjectStore('pedidos', { keyPath: 'id', autoIncrement: true });
    s.createIndex('por-cliente', 'clienteId', { unique: false });
  },
  2: (db, tx) => {
    tx.objectStore('pedidos').createIndex('por-fecha', 'fecha', { unique: false });
  }
});

await transaccion(db, ['pedidos'], 'readwrite', (tx) => {
  const s = tx.objectStore('pedidos');
  s.put({ clienteId: 7, fecha: new Date('2026-03-01'), total: 42 });
  s.put({ clienteId: 7, fecha: new Date('2026-04-15'), total: 19 });
  s.put({ clienteId: 9, fecha: new Date('2026-05-02'), total: 88 });
});

const delCliente7 = await transaccion(db, ['pedidos'], 'readonly', (tx) =>
  promesa(tx.objectStore('pedidos').index('por-cliente').getAll(7)));
console.table(delCliente7);
console.log('Abre el panel de aplicacion y busca la base "demo-devtools".');

Fíjate en dos detalles que resuelven problemas reales. El primero es que las migraciones se aplican en cadena desde la versión antigua, no como un bloque único: eso permite que un usuario que venía de la versión uno reciba las migraciones dos y tres en orden. El segundo es el manejador de cambio de versión, que cierra la conexión cuando otra pestaña actualiza la base; sin él, la otra pestaña se queda bloqueada indefinidamente.

Tres causas de una transacción que no completa

La transacción se cerró sola. Una transacción permanece activa mientras haya peticiones pendientes en ella y se cierra automáticamente cuando el bucle de eventos se queda sin trabajo suyo. Si dentro de una transacción haces await de algo que no sea una petición de la propia base —una llamada de red, un temporizador— la transacción se cierra antes de que vuelvas y las operaciones siguientes fallan. Es el error más común y el más desconcertante, porque el código parece correcto.

Una restricción de unicidad falló. Escribir un valor que viola un índice único aborta la transacción entera, no solo esa operación. Si no hay manejador de error, el fallo es silencioso.

La cuota se agotó. La escritura falla con un error de cuota y aborta la transacción. Se comprueba con la estimación de almacenamiento.

La regla que evita el primer caso, que es el más frecuente: dentro de una transacción, no esperes nada que no sea una petición de esa transacción. Prepara los datos antes, abre la transacción, escribe, y cierra.

Cuándo usarlo

IndexedDB es la elección correcta para cuatro casos, y una complicación innecesaria para el resto.

Cuando el volumen supera lo que el almacenamiento web admite. Cuando hace falta consultar por algo que no es una clave única, es decir, cuando hay índices. Cuando el acceso tiene que ocurrir desde un worker o desde un service worker. Y cuando se guardan datos binarios: ficheros, imágenes, respuestas.

Para guardar cuatro preferencias del usuario, el almacenamiento web es más simple y suficiente. Para una caché de respuestas de red, la caché del service worker está mejor diseñada para eso. La complejidad de esta API solo se paga cuando alguna de las cuatro condiciones se cumple.

El almacenamiento del navegador es un desalojo esperando a ocurrir, y diseñar como si fuera permanente es el error de fondo

Hay una propiedad de todo el almacenamiento del navegador que conviene entender antes de decidir qué se guarda ahí, y que se aplica igual a esta API que a todas las demás de este nivel: el navegador puede borrarlo, sin avisar y sin preguntar. Cuando el disco del dispositivo se llena, el navegador aplica una política de desalojo por origen, y los orígenes que el usuario visita poco son los primeros. En algunos casos hay además un desalojo por antigüedad: un origen que no se visita durante un periodo largo pierde sus datos. Y el usuario puede vaciarlo todo desde la configuración con dos clics, cosa que hace más a menudo de lo que los desarrolladores imaginan. Eso significa que el almacenamiento del navegador es una caché, no una base de datos, por muy transaccional que sea la API, y cualquier diseño que asuma permanencia tiene un modo de fallo catastrófico: el usuario que pierde el trabajo que creía guardado. Hay una forma de mejorar las probabilidades, que es solicitar almacenamiento persistente con la API correspondiente; si se concede, el origen queda exento del desalojo automático. Pero la concesión depende de heurísticas del navegador —si el sitio está instalado, si el usuario lo visita con frecuencia, si le ha dado permisos— y no se puede dar por hecha ni comprobar de forma fiable en el momento de diseñar. Las consecuencias prácticas son tres y merecen ser explícitas en cualquier proyecto que guarde datos localmente. Primera: si el dato es del usuario y perderlo tiene coste, tiene que estar también en el servidor. El almacenamiento local puede ser la copia rápida, nunca la única. Segunda: la aplicación tiene que arrancar bien con el almacén vacío, y esa ruta hay que probarla explícitamente, no suponerla. Tercera: cualquier flujo que dependa de datos locales necesita una respuesta a la pregunta de qué pasa si no están, y la respuesta no puede ser una pantalla en blanco. La comprobación que cierra las tres es la más simple del nivel y casi nadie la hace: borrar todo el almacenamiento del origen desde este panel, recargar, y usar la aplicación cinco minutos. Si algo se rompe, acabas de encontrar un bug que un usuario real va a encontrar en el peor momento posible.