EdgeCases Logo
Feb 2026
Browser APIs
Expert
8 min read

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
async-await
browser-apis
storage
edge-case
data-consistency

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

  • await yields 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

Related Insights

Explore related edge cases and patterns

TypeScript
Surface
TypeScript's satisfies Operator: Validate Without Widening
6 min
CSS
Deep
CSS Scroll-Driven Animations: When animation-timeline Breaks Your Layout
7 min

Advertisement