Handling deadlocks in IndexedDB readwrite transactions
Problem Statement
Concurrent readwrite transactions targeting overlapping object stores frequently trigger browser-level lock contention. In offline-first architectures, this manifests as:
AbortError: The transaction was aborted due to lock contentionTransactionInactiveError: DOMExceptionthrown when subsequent store operations attempt to execute on a completed transaction- Main thread stalls during high-frequency background sync, degrading PWA responsiveness
Before implementing concurrency controls, verify baseline storage constraints and schema design against IndexedDB Architecture & Advanced Patterns. Unoptimized schemas exacerbate lock contention.
Root Cause Analysis
IndexedDB enforces exclusive locks on object stores opened in readwrite mode. When parallel transactions request overlapping stores, the browser queues them sequentially. If the transaction’s request queue empties before all pending operations complete (e.g., because an await yielded the event loop), the runtime automatically commits the transaction, and subsequent IDB calls on it throw TransactionInactiveError.
Common triggers include:
- Multiple async functions invoking
db.transaction([store], 'readwrite')simultaneously - Overlapping key ranges in parallel
put/addoperations - Long-running cursor iterations that hold locks past browser timeout limits
Proper IndexedDB Transaction Management requires strict serialization, minimal lock scope, and deterministic retry logic.
Step-by-Step Implementation Fix
1. Serialize Overlapping Transactions via a Promise Queue
Replace direct transaction instantiation with a serialized queue. This guarantees sequential execution, eliminating race conditions.
// Module-level queue state (use a class or closure in production apps)
let txQueue = Promise.resolve();
/**
* Enqueues a readwrite operation and returns a promise resolving on commit.
* Handles AbortError mapping and transaction lifecycle.
*/
export function enqueueWrite(db, storeName, callback) {
const currentTask = txQueue.then(() => {
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readwrite');
const store = tx.objectStore(storeName);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error || new Error('Transaction failed'));
tx.onabort = () =>
reject(new DOMException('Lock contention detected', 'AbortError'));
try {
// callback() must call only synchronous IDB methods
callback(store);
} catch (err) {
tx.abort();
reject(err);
}
});
});
// Advance the queue (swallow queue errors to prevent chain breakage)
txQueue = currentTask.catch(() => {});
return currentTask;
}
2. Apply Exponential Backoff Retry for Aborted Transactions
Wrap the queue in a retry mechanism that specifically targets AbortError while surfacing fatal errors (e.g., QuotaExceededError, DataError) immediately.
export async function safeWrite(db, storeName, payload, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
await enqueueWrite(db, storeName, (store) => store.put(payload));
return; // Success
} catch (err) {
// Handle explicit lock contention
if (err.name === 'AbortError' && attempt < maxRetries - 1) {
const delay = Math.pow(2, attempt) * 100; // 100ms, 200ms, 400ms
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
// Surface fatal errors immediately (Quota, Schema mismatch, etc.)
if (err.name === 'QuotaExceededError') {
throw new Error('Storage quota exceeded. Clear cache or prompt user.');
}
throw err;
}
}
}
3. Minimize Lock Scope
Reduce contention by isolating transactions to single stores and avoiding unnecessary multi-store arrays.
// ❌ High contention risk
db.transaction(['users', 'sessions', 'logs'], 'readwrite');
// ✅ Production-safe: single-store scope
db.transaction('users', 'readwrite');
Validation Protocol
Verify deadlock resolution before shipping to production:
- Concurrency Stress Test: Execute 50 concurrent
safeWrite()calls targeting the same store. Assert zeroAbortErrororTransactionInactiveErroroccurrences. - Transaction State Inspection: Open DevTools > Application > IndexedDB. Monitor the Transactions panel. Verify states transition
pending→active→completewithoutabortedflags during sync bursts. - Lock Duration Measurement: Log
performance.now()immediately beforedb.transaction()and inside thetx.oncompletecallback. Assert durations remain under 50 ms for standard payloads. - Queue Serialization Assertion: Replace
txQueuewith a proxy that logs execution order. Confirm strict FIFO resolution under simulated offline/online network toggles.