The IndexedDB Transaction Auto-Commit Timing Bug
You open a readwrite transaction, do one write, await fetch() to grab a value, then do a second write — and the second write throws TransactionInactiveError. The transaction was alive a moment ago. The cause is IndexedDB’s auto-commit rule: a transaction stays active only while it has pending requests, and the instant control returns to the event loop with none outstanding, the browser commits and closes it. Awaiting any non-IndexedDB promise opens exactly that gap. This guide reproduces the bug and shows the correct pattern. It belongs to IndexedDB Transaction Management within IndexedDB Architecture & Advanced Patterns.
Root cause: transactions live only while requests are pending
The IndexedDB specification keeps a transaction in the active state only while there is at least one request that has not yet fired its success or error event. When the last pending request settles and control returns to the event loop, the transaction transitions to inactive and the browser schedules its commit. There is no commit() you forgot to call — committing is automatic and eager.
The trap is async/await. An await yields to the microtask queue. If the thing you await is another IndexedDB request, the transaction has a pending request to keep it alive. But if you await a fetch, a setTimeout-wrapped promise, a crypto.subtle call, or any unrelated promise, then at the moment of the await there are no pending IndexedDB requests — so the transaction commits and closes while your awaited work runs. The next store.put() against that transaction throws TransactionInactiveError. This is the same class of lifecycle hazard discussed in Handling Deadlocks in IndexedDB Read/Write Transactions, seen from the timing side.
Reproducing the bug
This function looks reasonable and fails every time the await fetch resolves after the transaction has gone inactive:
// BROKEN: awaiting fetch inside the transaction lets it auto-commit.
async function saveWithRemoteEnrichmentBroken(
db: IDBDatabase,
draft: { id: string; title: string },
): Promise<void> {
const tx = db.transaction('notes', 'readwrite');
const store = tx.objectStore('notes');
store.put({ ...draft, status: 'saving' }); // request #1, tx active
// The await yields with no pending IDB request -> tx auto-commits here.
const res = await fetch(`/enrich/${draft.id}`);
const enrichment = await res.json();
// Throws: DOMException: Failed to execute 'put' on 'IDBObjectStore':
// The transaction has finished. (TransactionInactiveError)
store.put({ ...draft, ...enrichment, status: 'saved' });
}
Wrapping the whole thing in try/catch does not help; the put call itself throws synchronously because the transaction object is no longer active.
The fix: gather external data before opening the transaction
The reliable rule is: never await anything that is not an IndexedDB request while a transaction is open. Do all network and CPU work first, then open the transaction and run every read and write synchronously within it.
function promisifyRequest<T>(req: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
function txDone(tx: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error ?? new Error('transaction aborted'));
});
}
// CORRECT: all I/O happens before the transaction; the tx body is synchronous.
async function saveWithRemoteEnrichment(
db: IDBDatabase,
draft: { id: string; title: string },
): Promise<void> {
// 1. Do every non-IDB await FIRST, with no transaction open.
const res = await fetch(`/enrich/${draft.id}`);
const enrichment = await res.json();
// 2. Open the transaction and issue all requests back-to-back.
const tx = db.transaction('notes', 'readwrite');
const store = tx.objectStore('notes');
store.put({ ...draft, status: 'saving' });
store.put({ ...draft, ...enrichment, status: 'saved' });
// 3. Await the transaction's own completion event (this is an IDB event,
// so it does not break the active-while-pending rule).
await txDone(tx);
}
When you genuinely need to read from the database before deciding what to fetch, do it in two transactions: a readonly one to read, then await your network call with no transaction open, then a readwrite one to write the result.
async function readThenEnrichThenWrite(
db: IDBDatabase,
id: string,
): Promise<void> {
// Transaction A: read what we need, then let it commit.
const readTx = db.transaction('notes', 'readonly');
const current = await promisifyRequest(
readTx.objectStore('notes').get(id),
);
// No transaction is open across this await — safe.
const res = await fetch(`/enrich/${id}?rev=${current?.rev ?? 0}`);
const enrichment = await res.json();
// Transaction B: write the result synchronously.
const writeTx = db.transaction('notes', 'readwrite');
writeTx.objectStore('notes').put({ ...current, ...enrichment });
await txDone(writeTx);
}
A promise wrapper that keeps the transaction alive correctly
You can still use await inside a transaction as long as everything you await is an IndexedDB request, because each request keeps the transaction active. The wrapper below is safe; chaining await promisifyRequest(...) calls keeps a pending request alive at each step.
async function moveRecord(
db: IDBDatabase,
fromStore: string,
toStore: string,
key: IDBValidKey,
): Promise<void> {
const tx = db.transaction([fromStore, toStore], 'readwrite');
// Each await here resolves on an IDB success event, so the next statement
// still runs inside the active transaction window. No external awaits.
const value = await promisifyRequest(tx.objectStore(fromStore).get(key));
if (value !== undefined) {
await promisifyRequest(tx.objectStore(toStore).put(value));
await promisifyRequest(tx.objectStore(fromStore).delete(key));
}
await txDone(tx);
}
Verification
Run the broken and fixed versions side by side and assert the difference:
async function verifyAutoCommit(db: IDBDatabase): Promise<void> {
let brokeAsExpected = false;
try {
await saveWithRemoteEnrichmentBroken(db, { id: 'x', title: 'demo' });
} catch (err) {
brokeAsExpected =
err instanceof DOMException && err.name === 'TransactionInactiveError';
}
console.assert(brokeAsExpected, 'expected TransactionInactiveError');
// The fixed path must complete without throwing.
await saveWithRemoteEnrichment(db, { id: 'x', title: 'demo' });
console.info('Fixed path committed both writes successfully');
}
In the browser you will see the broken call surface as an uncaught DOMException: The transaction has finished in the console, with name equal to TransactionInactiveError. The fixed call logs nothing and the record ends in the saved state — confirm in DevTools → Application → IndexedDB.
Edge cases and a fallback
Safari is stricter. WebKit closes transactions especially aggressively. Code that occasionally survives an accidental microtask gap on Chrome (because a queued request happened to keep the transaction alive) fails reliably on Safari and in iOS home-screen apps. Treat Safari’s behavior as the spec-correct baseline and never rely on a transaction surviving any non-IDB await.
Promise-based IDB wrappers. Thin wrapper libraries that promisify IndexedDB do not change the lifetime rule — they make it easier to accidentally break by sprinkling await everywhere. The rule still holds: an await on a wrapped IDB request is fine, an await on anything else inside the transaction is not. If a wrapper offers a “transaction lives until this async callback returns” mode, read its docs carefully, because most implement that by re-deriving the store on each tick and cannot bridge a real network await.
Never await fetch inside a transaction. This is the one-line takeaway. Network latency is unbounded and the transaction window is sub-millisecond; the two can never coexist. Gather remote data first. For mutations that depend on a server round-trip as part of a sync flow, drive them through Background Sync API Implementation rather than holding a transaction open.
Frequently Asked Questions
Why does awaiting another IndexedDB request not break the transaction?
Because that request is itself pending against the same transaction, which keeps it in the active state. The transaction only auto-commits when there are zero outstanding IDB requests and control returns to the event loop. A fetch or setTimeout promise is not an IDB request, so during its await the transaction has nothing keeping it alive.
Can I call tx.commit() to control timing instead?
IDBTransaction.commit() only requests an earlier commit of an already-active transaction; it cannot revive an inactive one or extend its lifetime across an external await. The durable fix is structural: do all non-IDB work before opening the transaction.
The error sometimes does not appear on Chrome. Is my code fine?
No. Chrome may occasionally keep the transaction alive by luck of microtask scheduling, but Safari and the spec do not. Code that intermittently passes is still broken; verify against the strict behavior described in IndexedDB Transaction Management and remove every external await from inside transactions.
Related
- IndexedDB Transaction Management — the parent guide on transaction scope, modes, and lifetime.
- Handling Deadlocks in IndexedDB Read/Write Transactions — the locking side of the same lifecycle rules.
- Background Sync API Implementation — the right home for mutations that need a network round-trip.
- IndexedDB Architecture & Advanced Patterns — the overall data-layer design these transaction rules support.