Handling deadlocks in IndexedDB readwrite transactions
Problem Statement
Concurrent readwrite transactions targeting overlapping object stores frequently trigger browser-level deadlock detection. 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 dead 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 a transaction holds a lock beyond engine-specific thresholds (typically ~30s in Chromium, variable in WebKit/Gecko), the runtime automatically aborts pending operations to prevent main-thread blocking.
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(async () => {
const tx = db.transaction(storeName, 'readwrite');
const store = tx.objectStore(storeName);
try {
await callback(store);
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error || new Error('Transaction failed'));
tx.onabort = () => reject(new DOMException('Lock contention detected', 'AbortError'));
});
} catch (err) {
// Fallback for synchronous failures before tx commits
tx.abort();
throw 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
Transactionspanel. Verify states transitionpending→active→completewithoutabortedflags during sync bursts. - Lock Duration Measurement: Log
performance.now()immediately beforedb.transaction()and inside thetx.oncompletecallback. Assert durations remain under 50ms for standard payloads. - Queue Serialization Assertion: Replace
txQueuewith a proxy that logs execution order. Confirm strict FIFO resolution under simulated offline/online network toggles.