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:

Before implementing concurrency controls, verify baseline storage constraints and schema design against IndexedDB Architecture & Advanced Patterns. Unoptimized schemas exacerbate lock contention.

Serializing contended writes through a promise queue A diagram contrasting three parallel transactions colliding on one store with the same writes serialized through a FIFO promise queue. Parallel writes collide tx A tx B tx C store AbortError Serialized through a FIFO queue A B C store one lock at a time

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:

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:

  1. Concurrency Stress Test: Execute 50 concurrent safeWrite() calls targeting the same store. Assert zero AbortError or TransactionInactiveError occurrences.
  2. Transaction State Inspection: Open DevTools > Application > IndexedDB. Monitor the Transactions panel. Verify states transition pendingactivecomplete without aborted flags during sync bursts.
  3. Lock Duration Measurement: Log performance.now() immediately before db.transaction() and inside the tx.oncomplete callback. Assert durations remain under 50 ms for standard payloads.
  4. Queue Serialization Assertion: Replace txQueue with 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