Handling deadlocks in IndexedDB readwrite transactions
Concurrent readwrite transactions that target overlapping object stores are the most common source of AbortError and stalled background sync in offline-first PWAs. This guide diagnoses the lock-contention pattern that masquerades as a deadlock and gives a copy-pasteable fix: serialize contended writes through a promise queue, retry aborted transactions with exponential backoff, and shrink lock scope. For the broader lifecycle, locking, and retry model these techniques build on, start with the parent guide on IndexedDB Transaction Management.
Problem Statement
In offline-first architectures, concurrent readwrite transactions targeting overlapping object stores frequently trigger browser-level lock contention. 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. The specification does not define a true multi-resource deadlock the way a relational database does — IndexedDB acquires all of a transaction’s store locks atomically before it becomes active, so two transactions cannot each hold a lock the other needs. What developers call a “deadlock” is therefore almost always one of two distinct failures: a transaction that auto-committed before its work finished, or a writer starved behind a long-running lock holder.
The auto-commit case dominates. If a transaction’s request queue empties before all pending operations complete (for example, because an await yielded the event loop), the runtime automatically commits the transaction, and subsequent IDB calls on it throw TransactionInactiveError. The starvation case appears as an AbortError when a queued transaction waits past the browser’s idle timeout for a lock that a long cursor iteration never releases.
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. The microtask-level detail of the auto-commit failure is broken down in The IndexedDB Transaction Auto-Commit Timing Bug.
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: locks three stores for the whole transaction
db.transaction(['users', 'sessions', 'logs'], 'readwrite');
// Production-safe: single-store scope releases promptly
db.transaction('users', 'readwrite');
Use put() with a stable key rather than add() inside the queued callback so that a retried write is idempotent — replaying it after a transient abort cannot create a duplicate record. For cross-tab serialization (the queue above only serializes within a single page context), promote the same logic to the Web Locks API for Cross-Tab Coordination, which holds a named lock across every tab and worker on the origin.
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.
Edge Cases and a Fallback Approach
The in-page promise queue solves contention within a single document, but it does nothing for two browser tabs of the same origin writing concurrently — each tab owns its own txQueue, and the two queues still collide at the storage layer. When cross-tab writes are possible, the Web Locks API is the correct fallback: acquire a named lock with navigator.locks.request('idb-write', ...) and perform the transaction inside the granted callback so only one tab writes at a time.
A second edge case is the long cursor. If a readwrite cursor iterates thousands of records, it can hold its lock past the idle timeout and starve queued writers. Break such work into chunked transactions of a few hundred records each, letting the queue interleave other writes between chunks. The batch-chunking pattern in IndexedDB Transaction Management shows the shape of this loop. When the records are large binaries, also review Storing Blobs & Files in IndexedDB, since oversized payloads shrink the safe chunk size and lengthen lock hold times.
Frequently Asked Questions
Does IndexedDB have true deadlocks like a SQL database?
No. IndexedDB acquires all of a transaction’s store locks atomically before the transaction becomes active, so two transactions can never each hold a lock the other is waiting for. What looks like a deadlock is almost always either an auto-commit that closed the transaction early (raising TransactionInactiveError) or a writer starved behind a long lock holder (raising AbortError). Serializing writes and keeping scopes short resolves both.
Why does my transaction throw AbortError only under heavy load?
Under load, many readwrite transactions queue for the same store and some wait past the browser’s idle timeout for the lock, which the runtime resolves by aborting them. Funnel writes through a single FIFO promise queue so only one transaction is active at a time, and wrap it in exponential-backoff retry so the rare abort that slips through is replayed automatically after the contending transaction commits.
Will the promise queue prevent contention across multiple tabs?
No. A module-level txQueue only serializes writes inside one document; each tab has its own queue, and the two still collide at the storage layer. For cross-tab safety, acquire a named lock through the Web Locks API for Cross-Tab Coordination and run the transaction inside the granted callback, so only one tab on the origin writes at a time.
Related
- IndexedDB Transaction Management — the parent guide on lifecycle, locking, retry, and batch chunking.
- The IndexedDB Transaction Auto-Commit Timing Bug — the microtask race behind premature commits and
TransactionInactiveError. - Web Locks API for Cross-Tab Coordination — serialize writes across tabs and workers, not just within one page.
- Storing Blobs & Files in IndexedDB — how large binary payloads affect chunk size and lock hold time.
- IndexedDB Architecture & Advanced Patterns — schema and storage architecture context for tuning contention.