Transacciones: alcance, modo y autocierre
Los dos modos de transacción de IndexedDB, el alcance que se declara por adelantado y no se puede ampliar, y la regla que sorprende a todo el mundo: la transacción se cierra sola si dejas de alimentarla.
En IndexedDB toda operación ocurre dentro de una transacción, incluso las que parecen triviales. No es ceremonia: es lo que da atomicidad, aislamiento y durabilidad a una base de datos que vive dentro de una pestaña que el usuario puede cerrar en cualquier instante. Pero el modelo trae una regla que no existe en ningún otro motor que hayas usado: la transacción no espera a que tú termines, sino que se compromete sola en cuanto detecta que has dejado de darle trabajo. Esa regla, invisible mientras el código es síncrono, se convierte en la fuente número uno de errores en cuanto aparece la primera espera.
- Distinguir los modos de solo lectura y de lectura y escritura, y qué garantiza cada uno.
- Declarar el alcance con criterio y entender por qué no se puede ampliar.
- Predecir el orden de ejecución y el aislamiento entre transacciones solapadas.
- Reconocer la regla del autocierre y convivir con ella al usar promesas.
Modo y alcance se declaran de una vez
Una transacción nace con dos decisiones tomadas: sobre qué almacenes actúa y en qué modo. Ninguna de las dos se puede cambiar después.
El alcance es la lista de almacenes que vas a tocar. Si intentas abrir uno que no declaraste, recibes NotFoundError. Declarar de más es tentador y caro: cada almacén incluido en un alcance de escritura queda bloqueado para las demás transacciones que lo necesiten.
El modo admite dos valores en tu código: readonly y readwrite. Existe un tercero, versionchange, pero solo lo crea el motor durante una migración y su alcance es siempre la base entera.
// Alcance de un solo almacen, solo lectura
const lectura = db.transaction("notas", "readonly");
// Alcance multiple y escritura, con durabilidad relajada
const escritura = db.transaction(["notas", "adjuntos"], "readwrite", {
durability: "relaxed",
});
escritura.oncomplete = () => console.log("comprometida");
escritura.onabort = () => console.warn("revertida", escritura.error);
Con durability: "relaxed" el motor no fuerza el vaciado a disco al comprometer. Ganas un orden de magnitud en escrituras pequeñas y repetidas, a cambio de poder perder los últimos milisegundos si el sistema operativo cae. Para datos que además sincronizas es un intercambio casi siempre razonable.
Orden y aislamiento
Las transacciones arrancan en el orden en que las creas, y ese orden es determinista. Sobre él se aplica una regla sencilla: dos transacciones de solo lectura pueden ejecutarse a la vez aunque compartan almacenes; una de escritura excluye a cualquier otra cuyo alcance se solape con el suyo, que quedará esperando su turno.
El resultado es un aislamiento equivalente al serializable, sin que tengas que pedirlo. Dentro de una transacción ves un estado congelado y coherente: nadie va a modificar bajo tus pies el almacén que estás recorriendo.
flowchart LR
A[Transaccion creada] --> B[Activa durante el turno actual]
B --> C{Quedan peticiones pendientes}
C -->|Si| D[Inactiva pero viva esperando resultados]
D --> E[Llega un evento success y vuelve a activarse]
E --> C
C -->|No| F[Autocommit y evento complete]
B --> G[Error no capturado o abort explicito]
G --> H[Reversion total y evento abort]Las peticiones dentro de una misma transacción también se atienden en orden de emisión, lo que permite razonar sobre secuencias sin sincronizar nada a mano: si lanzas un put y luego un get de la misma clave, el segundo verá lo que escribió el primero.
El evento de error de una petición burbujea hasta la transacción y, si nadie lo detiene, la aborta con todo lo que llevara hecho. Para tolerar el fallo de una operación concreta tienes que llamar a preventDefault en su manejador de error. El silencio equivale a una reversión total.
La regla del autocierre
Aquí está la trampa. Una transacción de IndexedDB está activa durante el turno del bucle de eventos en que la creaste y durante el despacho de cada evento de resultado de sus peticiones. Fuera de esos momentos está inactiva. Y en cuanto queda inactiva sin peticiones pendientes, el motor la compromete y la cierra.
No hay temporizador, no hay tiempo de espera y no hay forma de mantenerla abierta artificialmente. La transacción vive exactamente mientras la alimentas: cada nueva petición emitida desde el manejador de la anterior le devuelve la vida. Es un modelo de cadena, no de sesión.
La cadena la mantiene viva
Emitir una petición dentro del manejador de éxito de la anterior es lo que prolonga la transacción. Es el mecanismo, no un truco.
Cualquier espera ajena la mata
Una petición de red, un temporizador o un evento del usuario devuelven el control al bucle. Al volver, la transacción ya no existe.
El commit explícito
Llamar a tx.commit() cierra la transacción sin esperar a la heurística del motor. Mide bien en escrituras masivas.
Las microtareas sí sobreviven
Esperar una promesa ya resuelta se resuelve como microtarea dentro del mismo turno y, en los navegadores actuales, no cierra la transacción.
const tx = db.transaction("notas", "readwrite");
const notas = tx.objectStore("notas");
// MAL: la red devuelve el control al bucle de eventos
const respuesta = await fetch("/api/plantilla");
notas.put(await respuesta.json()); // TransactionInactiveError
// BIEN: primero lo externo, despues la transaccion
const plantilla = await (await fetch("/api/plantilla")).json();
const tx2 = db.transaction("notas", "readwrite");
tx2.objectStore("notas").put(plantilla);
tx2.commit();
El error resultante, TransactionInactiveError, es honesto pero desconcertante la primera vez, porque el código parece correcto y la transacción parece existir: el objeto sigue ahí, simplemente ya se comprometió y ha dejado de aceptar trabajo.
Convivir con promesas
Envolver la API en promesas es correcto y recomendable, siempre que el envoltorio respete la regla. La pieza mínima convierte una petición en una promesa; la pieza que falta en casi todas las implementaciones caseras es la promesa de la transacción entera, que es la única señal fiable de que los datos están comprometidos.
const prometer = (peticion) =>
new Promise((resolver, rechazar) => {
peticion.onsuccess = () => resolver(peticion.result);
peticion.onerror = () => rechazar(peticion.error);
});
const terminada = (tx) =>
new Promise((resolver, rechazar) => {
tx.oncomplete = () => resolver();
tx.onabort = () => rechazar(tx.error);
tx.onerror = () => rechazar(tx.error);
});
async function guardarLote(db, notas) {
const tx = db.transaction("notas", "readwrite");
const store = tx.objectStore("notas");
// Todas las peticiones se emiten en el mismo turno
const escrituras = notas.map((n) => prometer(store.put(n)));
await Promise.all(escrituras);
await terminada(tx); // solo aqui hay durabilidad
return escrituras.length;
}
Esperar únicamente a la última escritura no es suficiente: la petición puede haber tenido éxito y la transacción abortar después, dejándote con un valor de retorno que miente. La biblioteca idb de Jake Archibald expone exactamente esta distinción con tx.done, y esa es la razón por la que merece la pena usarla en lugar de reescribirla.
Emitir las peticiones en ráfaga dentro de una sola transacción es órdenes de magnitud más rápido que abrir una transacción por escritura, porque el coste fijo de crear, planificar y comprometer se paga una vez. El límite lo pone la memoria, no la API.
La reacción instintiva ante el autocierre es leerlo como una carencia —falta un método para mantener viva la transacción, falta un tiempo de espera configurable— y esa lectura invierte por completo el problema que la especificación estaba resolviendo. IndexedDB se ejecuta dentro de una pestaña, es decir, dentro de un entorno que puede quedarse congelado por un alert, suspendido por el gestor de energía, aparcado en segundo plano por el sistema operativo o esperando eternamente a que un usuario que se fue a comer haga clic en algo. Si una transacción de escritura pudiera permanecer abierta a voluntad del programador, bastaría con una pestaña olvidada para dejar bloqueados sus almacenes para todas las demás pestañas del mismo origen, de forma indefinida y sin ningún mecanismo de rescate, porque no existe un administrador de base de datos al que llamar para matar la sesión colgada. Al atar la vida de la transacción a la presencia de trabajo pendiente, la especificación hace estructuralmente imposible ese escenario: una transacción solo dura lo que dura una ráfaga de operaciones, y ninguna espera humana o de red puede colarse dentro. Esto tiene una consecuencia arquitectónica directa que conviene interiorizar antes de escribir la primera línea de una aplicación local-first seria: la transacción es la unidad de trabajo, no la unidad de flujo. Todo lo que dependa de algo externo —una respuesta del servidor, una confirmación del usuario, un fichero que aún se está leyendo— ocurre fuera, y solo cuando ya tienes todos los datos en la mano abres la transacción, escribes en ráfaga y la cierras. Quien intenta modelar un caso de uso completo dentro de una transacción está peleando contra la propiedad que hace que IndexedDB sea seguro de usar en un entorno tan hostil como una pestaña del navegador.
- Abre una transacción de escritura, espera una petición de red dentro de ella y observa el
TransactionInactiveErrorexacto que recibes. - Reescribe el mismo caso obteniendo los datos antes y abriendo la transacción después: mide la diferencia de duración.
- Encadena mil escrituras dentro de una sola transacción y compáralo con mil transacciones de una escritura cada una.
- Repite la prueba anterior añadiendo
tx.commit()explícito y condurability: "relaxed", y anota el efecto de cada cambio.