Web Platform · Browser Storage

Transaction Lifetime and Durability in IndexedDB

Why an IndexedDB transaction commits without being asked, which callbacks keep it alive, how a failed request aborts the whole scope, and what the durability hint actually guarantees.

IndexedDB transactions have no begin and no explicit commit in ordinary use. A transaction is opened by naming object stores, it stays usable for as long as the browser considers it busy, and it commits when it stops being busy. The rule that decides “busy” is precise, and almost every TransactionInactiveError in real code comes from a program that yielded to the event loop at a moment the specification does not consider part of the transaction.

Scope and mode are fixed at creation

db.transaction(['orders', 'lines'], 'readwrite') returns a transaction whose scope is exactly those two stores. A store not named in the scope cannot be touched: the objectStore() call throws NotFoundError when the name is outside the transaction’s scope, and whether the store also exists in the database is a separate question. The mode is one of readonly, readwrite, or versionchange, and a readwrite transaction on a scope excludes every other transaction on an overlapping scope, readwrite or readonly; readonly transactions on overlapping scopes may proceed concurrently with each other.

Because scope is fixed at creation, a transaction cannot grow to include a store discovered later in the code path. The usual pattern is to name every store the unit of work can touch at the top, even if some branches never use them — the cost of a wider scope is contention, not correctness.

Active, inactive, and the microtask boundary

A transaction is active during the task in which it was created and during the callback that handles each of its requests. Between those moments it is inactive, and a request issued while inactive throws TransactionInactiveError. When the transaction has no pending requests and is not active, the browser commits it.

The subtlety is where the boundary falls. After a request’s success handler returns, microtasks — promise callbacks — run before control returns to the event loop and before the transaction is deactivated. A promise wrapper around IndexedDB requests therefore works:

function getAll(store) {
  return new Promise((resolve, reject) => {
    const req = store.getAll();
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

const tx = db.transaction(['orders', 'lines'], 'readonly');
const orders = await getAll(tx.objectStore('orders'));
const lines = await getAll(tx.objectStore('lines')); // still the same task's microtasks

That fragment survives because each await resumes in a microtask, and the transaction has a pending request or has not yet been deactivated. Replace getAll with a fetch and the transaction commits while the network request is in flight: no request is pending, the microtask queue drains, control returns to the event loop, and the transaction commits. The next objectStore() call throws. A transaction is a unit of database work, not a unit of application work; external I/O has to happen before it opens or after it closes.

transaction.commit() requests an early commit rather than waiting for the automatic one. It is useful when the last write has completed and the code would otherwise fall through into other work, but it does not make a transaction survive a yield; it only ends it sooner. transaction.abort() rolls the whole scope back.

A failed request aborts the scope by default

Errors in IndexedDB propagate in both directions. A request error fires error on the request, then bubbles to the transaction, then to the database. If nothing cancels the event with preventDefault(), the error’s default action is to abort the transaction. Every write made in that transaction is discarded, including writes that had already succeeded, and onabort fires instead of oncomplete.

This differs from systems where a statement error leaves the transaction open until the application decides. Here the safe default is rollback, and handling an error without aborting requires an explicit choice:

const req = store.add(record);
req.onerror = (event) => {
  if (req.error.name === 'ConstraintError') {
    event.preventDefault();      // keep the transaction alive
    event.stopPropagation();     // do not abort from the transaction level
    skipDuplicate(record);
  }
};

Constraint violations from a unique index surface as ConstraintError, a missing store as NotFoundError, a bad key as DataError, and an exceeded quota as QuotaExceededError. AbortError means the transaction was rolled back, often because the tab was closed or the database connection was closed while work was pending. In every case the writes in scope are gone; there is no partial commit and no way to salvage half a transaction.

Ordering inside a transaction is guaranteed: requests execute in the order they were issued, and a success handler for one request can issue further requests that run after all previously queued ones. That ordering, plus the all-or-nothing commit, is the entire consistency model. There are no isolation levels to choose, no dirty reads, and no nested transactions — a second transaction is a separate unit that queues behind an overlapping one.

What “complete” means under each durability setting

db.transaction(stores, 'readwrite', { durability: 'strict' }) selects how much the browser promises when it fires complete. With strict, the browser is expected to verify that the outstanding changes have reached a persistent medium before reporting completion, so a power loss immediately afterwards should not lose the transaction. With relaxed, completion may be reported once the changes have reached the operating system, and a crash or power loss can then lose a transaction that the page believed had committed. default leaves the choice to the implementation’s default behaviour for the storage bucket.

The option has no effect on a readonly transaction, which writes nothing, and it is a hint rather than a switch that disables durability. The trade is straightforward: strict costs a flush per transaction, which is slow relative to a relaxed commit but gives the guarantee an application needs when it is recording something the user believes is saved — a sent message, a completed payment step. Relaxed is appropriate for caches, view state, and rebuildable data, where re-fetching after a crash is cheaper than the flush.

// Durable commit for a write the user is told succeeded.
const tx = db.transaction('outbox', 'readwrite', { durability: 'strict' });
tx.objectStore('outbox').put(message);
tx.oncomplete = () => markSent(message.id);
tx.onabort = () => markFailed(message.id, tx.error);

Note that oncomplete is the only signal that the transaction committed. A success event on the last request means the request’s work is done, not that the transaction has committed, and a later constraint or quota failure inside the same scope can still roll everything back. Code that reports success from request handlers reports it too early.

Versionchange transactions and blocked connections

Opening a database with a higher version runs a versionchange transaction, which holds an exclusive lock on the entire database and can create and delete object stores and indexes. It is the only transaction in which createObjectStore and createIndex are legal, and it fires upgradeneeded on the open request rather than on the transaction.

Existing connections do not close on their own when an upgrade is requested. Each open connection receives a versionchange event instead, and the requesting open request receives blocked. If a page ignores the event and keeps its connection, the upgrade never starts. The connection must be closed in the handler — and because close() waits for open transactions to finish, the page must also stop starting new work. This is the mechanism behind the “database is blocked” state that appears when an old tab is left open; onversionchange is not optional housekeeping.

A connection can also be closed out from under a page. Calling db.close() marks the connection as closing and lets its transactions finish, but the browser may close connections on its own — when site data is cleared, when the quota manager needs to reclaim space, or when the storage backend restarts. The database fires a close event on the connection in those cases, and every transaction in flight aborts. Subsequent calls on the connection throw InvalidStateError; opening a new connection is the only recovery, and the application has to treat the loss of the connection as a loss of whatever had not yet committed.

This is why long-lived handles are a liability. A page that caches an IDBDatabase and assumes it is valid forever will eventually write into a dead connection, and the error surfaces as an abort rather than as a failure of the open call. The pattern that survives is to treat the connection as opened per operation group, with a reopen path in the failure handler, and to keep the durable state in the database rather than in the connection object.

Aborts also matter here: for an existing database, a versionchange transaction that aborts because the upgrade handler threw leaves the database at its previous version, with no schema change applied. The handler runs inside the transaction, so a schema change is all-or-nothing with the data migration that accompanies it.

References

  1. Indexed Database API 3.0 — W3C
  2. MDN — IDBTransaction — MDN Web Docs
  3. MDN — IDBDatabase — MDN Web Docs