IndexedDB Transaction Management: Patterns for Reliable Offline State

Building resilient offline-first applications requires precise control over browser storage boundaries. At the core of IndexedDB lies the transaction model, which dictates data isolation, durability guarantees, and concurrency behavior. Mismanaged transaction scopes are the primary cause of silent state corruption, UI thread blocking, and failed background syncs in production PWAs. This guide details production-ready patterns for IndexedDB transaction management, covering lifecycle control, lock contention mitigation, retry strategies, and batch optimization. For foundational context on storage architecture and versioning, review the parent guide on IndexedDB Architecture & Advanced Patterns before implementing the patterns below.

IndexedDB transaction lifecycle and auto-commit A state diagram showing how an IndexedDB transaction moves from inactive to active to committed, and how an await boundary causes premature auto-commit. transaction() scope + mode set active requests queued complete oncomplete fires auto-committed queue drained early TransactionInactiveError next request throws await yields the event loop

Transaction Lifecycle and Execution Contexts

IndexedDB transactions are strictly event-driven and bound to the JavaScript event loop. A transaction begins synchronously upon invocation but executes asynchronously, committing automatically when its internal request queue empties and no pending requests remain. This implicit lifecycle creates a common pitfall: developers often assume a transaction remains open across await boundaries, only to encounter TransactionInactiveError when the engine prematurely commits. This exact race is dissected in The IndexedDB Transaction Auto-Commit Timing Bug, which walks through the microtask sequence that closes the transaction.

To maintain explicit control over transaction boundaries:

Transactions that span multiple asynchronous boundaries (e.g., network fetches, heavy computations) must be explicitly restructured: offload CPU-intensive work outside the transaction context, then re-enter a fresh transaction scope for the final commit.

Transaction modes and parameters

The db.transaction(scope, mode, options) signature accepts three parameters. The table below summarizes how each one shapes locking and durability behavior.

Parameter Accepted values Effect
scope Store name string or array of names The set of object stores the transaction may touch; all of them are locked together.
mode readonly, readwrite, versionchange Lock strength. versionchange is reserved for onupgradeneeded schema work.
options.durability default, strict, relaxed Disk-flush guarantee. relaxed returns faster but risks loss on abrupt power-off; strict waits for the OS flush.

A transaction’s mode cannot be changed after creation, and you cannot widen its scope mid-flight — any request against an out-of-scope store throws NotFoundError. Plan the scope to cover every store a logical unit of work will touch before the first request fires.

Concurrency Models: Read-Only vs Read-Write Scopes

IndexedDB enforces strict concurrency controls to prevent data races. readonly transactions can execute concurrently across tabs, workers, and the main thread. readwrite transactions, however, acquire exclusive locks on targeted object stores. If a readwrite transaction is active, all subsequent transactions targeting the same store are queued until the lock releases.

Improper scope selection directly impacts perceived performance:

For deep dives into lock escalation, queue starvation, and resolution strategies, consult Handling Deadlocks in IndexedDB Read/Write Transactions.

Error Handling, Rollbacks, and Retry Strategies

Transaction failures trigger automatic rollbacks, but unhandled errors can silently corrupt offline queues or leave the application in an inconsistent state. Production-grade transaction management requires explicit error boundary handling and idempotent retry logic.

Key error handling principles:

Production-Ready Retry Pattern with Exponential Backoff

async function executeWithRetry<T>(
  db: IDBDatabase,
  storeNames: string | string[],
  mode: IDBTransactionMode,
  operation: (tx: IDBTransaction) => void,
  maxRetries = 3
): Promise<T> {
  let attempt = 0;

  while (attempt < maxRetries) {
    const result = await new Promise<T>((resolve, reject) => {
      const tx = db.transaction(storeNames, mode);
      let operationResult: T;

      tx.oncomplete = () => resolve(operationResult);
      tx.onerror = () => reject(tx.error);
      tx.onabort = () => reject(new Error('Transaction aborted'));

      // operation() must call only synchronous IDB methods and store the result
      try {
        const req = operation(tx) as unknown as IDBRequest<T>;
        if (req && 'onsuccess' in req) {
          req.onsuccess = () => { operationResult = req.result; };
        }
      } catch (err) {
        tx.abort();
        reject(err);
      }
    }).catch(async (error) => {
      attempt++;
      if (
        error instanceof DOMException &&
        error.name === 'QuotaExceededError'
      ) {
        throw error; // Don't retry quota errors
      }
      if (attempt >= maxRetries) throw error;

      // Exponential backoff with jitter
      const delay = Math.pow(2, attempt) * 100 + Math.random() * 50;
      await new Promise((res) => setTimeout(res, delay));
      return undefined as unknown as T;
    });

    if (result !== undefined) return result;
  }
  throw new Error('Transaction retries exhausted');
}

The critical distinction this pattern encodes is between transient and fatal failures. A QuotaExceededError will recur on every retry because the underlying disk budget is unchanged, so it short-circuits immediately. A lock-contention AbortError, by contrast, usually clears once the competing transaction commits, making it a textbook candidate for backoff. The jitter term prevents the thundering-herd effect where several aborted writers wake at the same instant and collide again.

Optimizing Batch Operations and Cursor Integration

Large-scale synchronization (e.g., initial app hydration, bulk cache updates) frequently triggers memory pressure and QuotaExceededError. IndexedDB does not stream results natively, so unbounded getAll() or massive put() loops will block the main thread and exhaust transaction memory limits.

Optimization strategies:

Safe ReadWrite Batch Processing with Quota Fallback

async function batchInsertWithQuotaHandling(
  db: IDBDatabase,
  storeName: string,
  items: Record<string, unknown>[],
  batchSize = 300
): Promise<{ success: number; failed: number }> {
  let successCount = 0;
  let failedCount = 0;

  for (let i = 0; i < items.length; i += batchSize) {
    const chunk = items.slice(i, i + batchSize);

    await new Promise<void>((resolve, reject) => {
      const tx = db.transaction(storeName, 'readwrite');
      const store = tx.objectStore(storeName);

      tx.oncomplete = () => {
        successCount += chunk.length;
        resolve();
      };
      tx.onerror = () => {
        if (tx.error?.name === 'QuotaExceededError') {
          reject(tx.error);
        } else {
          failedCount += chunk.length;
          resolve(); // Continue with next batch on non-quota errors
        }
      };

      for (const item of chunk) {
        store.put(item);
      }
    });
  }

  return { success: successCount, failed: failedCount };
}

When the records being persisted are large binary objects rather than plain JSON, the batch size needs to shrink dramatically — a few full-resolution images can exceed a comfortable per-transaction memory budget. The trade-offs of holding binary payloads inside a transaction are covered in Storing Blobs & Files in IndexedDB, which explains why streaming a Blob in is cheaper than base64-encoding it first.

Advanced Patterns for Complex State Synchronization

Modern offline-first architectures require deterministic conflict resolution, optimistic UI rendering, and seamless background sync integration.

Recommended architectural patterns:

When two clients mutate the same record offline, the order in which their WAL entries replay determines the final state. Pair the WAL with the Conflict Resolution Algorithms that suit your data model — last-write-wins for ephemeral flags, merge functions or CRDTs for collaborative documents.

Browser Compatibility and Known Bugs

Core transaction semantics are stable across engines, but auto-commit timing and durability handling diverge enough to matter in production.

Behavior Chrome / Edge Firefox Safari (incl. iOS 16/17)
Auto-commit on event-loop yield Aggressive; commits as soon as the queue drains Aggressive Historically the most aggressive; older WebKit committed even mid-microtask
durability: 'relaxed' honored Yes Yes Ignored on some versions; treated as default
Concurrent non-overlapping readwrite Yes Yes Yes, but iOS 16 occasionally serialized them under memory pressure
Large transaction stability Stable to tens of thousands of ops Stable iOS Safari can abort large transactions under background memory limits

The practical takeaway is to keep transactions short, never rely on await-ing a non-IDB promise inside one, and treat relaxed durability as a hint rather than a guarantee. iOS Safari’s tighter memory ceiling is the most common source of “works on desktop, aborts on iPhone” reports, which is why the batch sizes above stay conservative.

Performance and Scale Notes

Common Pitfalls & Troubleshooting

Pitfall Impact Mitigation
Long-running readwrite transactions Blocks concurrent UI updates and service worker syncs Keep scopes under 50 ms. Offload computation. Chunk large writes.
Ignoring onabort vs onerror Silent failures, untracked state drift Attach both handlers. Log tx.error explicitly. Implement fallback queues.
Nested transactions on same stores Lock escalation, TransactionInactiveError Flatten transaction scopes. Use a single transaction per logical unit of work.
Assuming oncomplete = disk flush Data loss on abrupt browser termination oncomplete is the JS-level commit guarantee. Use periodic checkpoints for critical state.
Unbounded batch operations QuotaExceededError, OOM crashes Implement chunking (200–500 items). Monitor navigator.storage.estimate(). Gracefully degrade on quota exhaustion.

Frequently Asked Questions

Why do IndexedDB readwrite transactions automatically abort after a few seconds?

The browser enforces an idle timeout on inactive transactions to prevent deadlocks and memory leaks. If the event loop is blocked, or if the transaction has no pending requests (all onsuccess have fired), it commits automatically. If you await a non-IDB promise inside a transaction, the event loop yields, the transaction closes, and subsequent IDB requests throw TransactionInactiveError. Keep transaction scopes tight and execute all IDB requests before any await. The full mechanism is dissected in The IndexedDB Transaction Auto-Commit Timing Bug.

Can I run multiple readwrite transactions on different object stores concurrently?

Yes. IndexedDB allows concurrent readwrite transactions as long as they target non-overlapping object stores. However, if a single transaction spans multiple stores, it acquires locks on all of them simultaneously, which can cause contention. Design your schema to isolate frequently updated entities into separate stores, and avoid cross-store transactions unless strict atomicity is required.

How do I ensure transaction durability during abrupt browser closures?

IndexedDB uses an internal write-ahead logging mechanism, but explicit transaction.oncomplete resolution is the only reliable guarantee of committed state from the JavaScript context. Implement periodic flushes for critical state, and avoid relying on beforeunload for final commits. Use the Background Sync API Implementation to queue uncommitted operations and retry them deterministically upon reconnection.

Should I reuse one transaction for a whole sync batch or open many small ones?

Open many small ones. A single long-lived transaction holds its store locks for the entire batch, blocking every other writer and risking an auto-commit abort if any await slips in. Chunking into independent transactions of 200–500 records each releases locks promptly between chunks and lets the runtime reclaim memory, which is essential on memory-constrained iOS Safari.

Related