IndexedDB Transaction Auto-Commit: The Await Trap
IndexedDB transactions auto-commit when the event loop has no pending requests. One await in the wrong place, and your data is inconsistent.
IndexedDB transactions auto-commit when there's no pending request on the event loop.
Mix in await, and your transaction silently commits mid-operation — leaving
your data in an inconsistent state. This is the single most common IndexedDB footgun,
and it's rooted in how the spec interacts with JavaScript's microtask queue.
The Auto-Commit Rule
From the IndexedDB spec: a transaction stays active only while there's a pending request placed against it. The moment the event loop processes a task and finds no pending IDB requests, the transaction commits.
const tx = db.transaction('store', 'readwrite');
const store = tx.objectStore('store');
// Request 1 — keeps transaction alive
const request1 = store.put({ id: 1, value: 'a' });
// Transaction is still active here because request1 is pending
request1.onsuccess = () => {
// Request 2 — placed synchronously in the callback, keeps it alive
store.put({ id: 2, value: 'b' }); // ✅ Works
};
// Later, when no more requests are pending → auto-commit
This works because each onsuccess callback runs synchronously in the same task,
and any new request extends the transaction's lifetime.
Why Await Breaks Everything
await yields control back to the event loop. Even await Promise.resolve()
schedules a microtask — and that's enough for IndexedDB to see "no pending requests" and commit:
async function saveData(db) {
const tx = db.transaction('store', 'readwrite');
const store = tx.objectStore('store');
store.put({ id: 1, value: 'first' });
await Promise.resolve(); // ← Microtask boundary
// Transaction has auto-committed by now!
store.put({ id: 2, value: 'second' }); // ❌ TransactionInactiveError
}
The error you'll see: TransactionInactiveError: The transaction is not active.
Your first write succeeds, your second silently fails to even attempt.
The Fetch Trap
async function syncFromServer(db) {
const tx = db.transaction('items', 'readwrite');
const store = tx.objectStore('items');
const existing = await idbRequest(store.get('key')); // ✅ OK so far
// Now fetch new data from server
const response = await fetch('/api/item'); // ← Transaction dies here
const item = await response.json();
store.put(item); // ❌ TransactionInactiveError
}
The fetch() await releases the event loop. By the time the response arrives
(milliseconds to seconds later), your transaction is long gone.
Pattern 1: Pre-fetch, Then Transact
The safest pattern — gather all data before opening the transaction:
async function syncFromServer(db) {
// Phase 1: Gather data (no transaction yet)
const response = await fetch('/api/items');
const items = await response.json();
// Phase 2: Write everything synchronously
const tx = db.transaction('items', 'readwrite');
const store = tx.objectStore('items');
for (const item of items) {
store.put(item); // All sync, transaction stays alive
}
return new Promise((resolve, reject) => {
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
});
}Pattern 2: Chain Requests via Callbacks
If you must read-then-write within a transaction, use the callback chain (old school but reliable):
function updateItem(db, id, modifier) {
return new Promise((resolve, reject) => {
const tx = db.transaction('items', 'readwrite');
const store = tx.objectStore('items');
const getReq = store.get(id);
getReq.onsuccess = () => {
const item = getReq.result;
const updated = modifier(item);
const putReq = store.put(updated);
putReq.onsuccess = () => resolve(updated);
putReq.onerror = () => reject(putReq.error);
};
getReq.onerror = () => reject(getReq.error);
tx.onerror = () => reject(tx.error);
});
}
// Usage
await updateItem(db, 'user-1', (user) => ({
...user,
lastLogin: Date.now()
}));Pattern 3: Promisify Without Breaking Transactions
You can use promises if you're careful to chain them within the same task:
function idbRequest(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function readMultiple(db, keys) {
const tx = db.transaction('items', 'readonly');
const store = tx.objectStore('items');
// ✅ All requests placed synchronously
const requests = keys.map(key => store.get(key));
// ✅ Awaiting here is safe — requests are already pending
const results = await Promise.all(requests.map(idbRequest));
return results;
}The key: place all requests before the first await. The transaction stays alive because those requests are pending while you await their completion.
Pattern 4: Multiple Transactions
If operations genuinely need async work between them, accept that you need multiple transactions:
async function complexSync(db) {
// Transaction 1: Read current state
const current = await readFromDB(db);
// Async work (transaction 1 is done)
const serverData = await fetch('/api/sync').then(r => r.json());
const merged = mergeData(current, serverData);
// Transaction 2: Write merged result
await writeToDB(db, merged);
}
// This is NOT atomic! If the write fails, you may have inconsistent state.
// For true atomicity, use the pre-fetch pattern or accept eventual consistency.The Microtask Trap: Even Trickier
Even queueMicrotask can kill your transaction:
const tx = db.transaction('store', 'readwrite');
const store = tx.objectStore('store');
store.put({ id: 1 });
queueMicrotask(() => {
store.put({ id: 2 }); // ❌ May fail depending on browser timing
});The spec says the transaction checks for pending requests at task boundaries, but microtasks are processed before the next task. In practice, browser implementations vary — never rely on microtask timing with IndexedDB.
Debugging Transaction State
// Check if transaction is still active
function isTransactionActive(tx) {
try {
// Attempt a read on any store
tx.objectStore(tx.objectStoreNames[0]).count();
return true;
} catch (e) {
return e.name !== 'TransactionInactiveError';
}
}
// Log transaction lifecycle
tx.oncomplete = () => console.log('Transaction committed');
tx.onabort = () => console.log('Transaction aborted:', tx.error);
tx.onerror = () => console.log('Transaction error:', tx.error);Key Takeaways
awaityields to the event loop → transaction auto-commits if no requests are pending- Place all IDB requests synchronously before any await
- For read-modify-write, use callback chains or place all operations in one synchronous batch
- Never await external resources (fetch, timers) inside an active transaction
- If you need async work between operations, use multiple transactions and handle consistency explicitly
- Consider libraries like idb or Dexie that handle these patterns correctly
Advertisement
Explore these curated resources to deepen your understanding
Official Documentation
Tools & Utilities
Related Insights
Explore related edge cases and patterns
Advertisement