IndexedDB Architecture & Advanced Patterns

For offline-first applications, IndexedDB remains the only browser-native storage mechanism capable of handling large, structured datasets with transactional guarantees. Where the synchronous key-value stores covered in Browser Storage Fundamentals & Quotas top out at a few megabytes of strings, IndexedDB gives you an asynchronous, indexed, multi-store database that can hold hundreds of megabytes of structured records, Blobs, and ArrayBuffers. This guide details the architectural patterns, quota management strategies, and concurrency controls required to build resilient PWA state persistence layers, and it connects directly to the durability concerns explored in Offline Sync Strategies & Background Workflows, where IndexedDB usually serves as the local source of truth behind a sync queue.

The five companion guides below each take one slice of this surface deeper: schema migrations, transaction concurrency, index design, cursor-based query tuning, and the mechanics of persisting binary files. Read this page for the architecture and trade-offs; follow the inline links when you need a copy-pasteable implementation.

IndexedDB architecture overview A diagram showing how a database connection holds versioned object stores and indexes, accessed through scoped transactions that yield cursors and key-range queries. IDBDatabase cached connection Transaction readonly / readwrite Object store Index Cursor keyPath record structured clone IDBKeyRange bounded query Blob / File binary payload Schema lives behind onupgradeneeded Version bumps gate every structural change to stores and indexes

The IndexedDB surface at a glance

Before diving into patterns, it helps to fix the vocabulary. A database is a named, versioned container scoped to an origin. Inside it live one or more object stores — document-oriented collections keyed by a primary key. Each store can carry indexes that maintain a sorted view over a property of the stored records. All reads and writes happen inside transactions, which are scoped to a set of stores and a mode. Cursors stream records out of a store or index without materializing the whole result set in memory. The table below maps each concept to the guide that covers it in depth.

Concept What it does Deep-dive guide
Object store + schema Holds records; structure changes only on version bump Database Schema Migrations
Transaction Scopes and isolates reads/writes; auto-commits IndexedDB Transaction Management
Index Sorted secondary key for fast lookups Indexing Strategies for Fast Queries
Cursor + key range Streams bounded result sets, drives pagination Query Optimization & Cursors
Blob / File storage Persists binary payloads efficiently Storing Blobs & Files in IndexedDB

1. Core Storage Architecture & Quota Management

Browser Compatibility & Version Support Matrix

IndexedDB is universally supported across modern Chromium, Firefox, and Safari engines, but implementation nuances dictate offline reliability. Chromium allocates up to 60% of available disk space per temporary storage group, while Safari historically enforced a stricter soft limit per origin for non-installed sites. Private browsing modes in Safari and Firefox historically disable persistent storage or route data to ephemeral memory, triggering InvalidStateError on initialization. Always feature-detect window.indexedDB and verify navigator.storage capabilities before committing to an offline-first architecture. Cross-browser testing must explicitly validate private/incognito contexts, as silent failures here corrupt sync queues.

Engine Persistent storage default Private mode behavior Notable caveat
Chromium Best-effort, up to ~60% of disk Ephemeral, cleared on session end Group quota shared across same-site origins
Firefox Best-effort; persist() prompts Memory-backed, no disk writes Quota counted per eTLD+1 group
Safari (macOS/iOS) Best-effort, evicted under ITP May throw on open or silently fail 7-day inactivity wipe for non-installed sites

Storage Quotas, Eviction Policies & Estimation

Browsers employ LRU (Least Recently Used) eviction policies when disk pressure mounts. Without explicit persistence requests, user data can be cleared automatically. The full mechanics of how each engine calculates and enforces these limits are covered in Storage Quotas & Eviction Policies. Implement proactive quota monitoring using the StorageManager API to prevent silent write failures and trigger cache pruning before limits are breached.

async function checkStorageQuota() {
  if (!navigator.storage?.estimate) return { available: Infinity };
  const { usage = 0, quota = 0 } = await navigator.storage.estimate();
  const buffer = 50 * 1024 * 1024; // 50 MB safety margin
  if (quota > 0 && quota - usage < buffer) {
    console.warn('Approaching storage limit. Triggering cache eviction.');
    await evictStaleCaches();
  }
  return { usage, quota, remaining: quota - usage };
}

For critical PWA state, request persistent storage via await navigator.storage.persist(). While not guaranteed, this signals the browser to exclude your origin from aggressive eviction sweeps. Monitor quota consumption during development using your browser’s Application or Storage developer panel, and simulate low-disk conditions to validate eviction handling before it surfaces in the field.

Object Store Design & Schema Versioning

Object stores function as document-oriented tables. Schema evolution requires strict version control via the onupgradeneeded lifecycle hook. Never mutate stores outside this event; doing so throws InvalidStateError. Incremental versioning ensures zero-downtime upgrades across client fleets. When designing migration pipelines, always check for existing stores before creation, and perform all schema changes using the implicit versionchange transaction provided to onupgradeneeded — never open a new transaction inside that event. For comprehensive zero-downtime upgrade workflows and data transformation pipelines, consult Database Schema Migrations, and for the failure-recovery edge cases see Recovering from a Failed IndexedDB Version Upgrade.

A robust upgrade handler routes through version numbers as fall-through cases, applying each incremental change exactly once regardless of which version the client started from:

const DB_NAME = 'app-state';
const DB_VERSION = 4;

function applyMigrations(db: IDBDatabase, oldVersion: number, tx: IDBTransaction): void {
  // Fall-through: a client on v1 runs every case up to DB_VERSION.
  if (oldVersion < 1) {
    db.createObjectStore('users', { keyPath: 'id' });
  }
  if (oldVersion < 2) {
    const orders = db.createObjectStore('orders', { keyPath: 'id' });
    orders.createIndex('byUser', 'userId', { unique: false });
  }
  if (oldVersion < 3) {
    // Add an index to an existing store via the upgrade transaction.
    tx.objectStore('orders').createIndex('byStatus', 'status', { unique: false });
  }
  if (oldVersion < 4) {
    db.createObjectStore('files', { keyPath: 'id' });
  }
}

The step-by-step procedure for sequencing these bumps in production is detailed in Step-by-Step IndexedDB Version Upgrade Migration.

2. Production-Ready Async Workflows & Connection Lifecycle

Modern async/await Patterns for IndexedDB

The legacy event-driven API (onsuccess/onerror) introduces callback nesting and error boundary fragmentation. Production systems must wrap IDBOpenDBRequest in promise-based abstractions that enforce async/await consistency. The following caches the connection and wraps the native callback API:

const DB_NAME = 'app-state-v2';
const DB_VERSION = 3;
let dbCache = null;

async function getDB() {
  if (dbCache) return dbCache;
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, DB_VERSION);
    request.onupgradeneeded = (e) => {
      const db = e.target.result;
      if (!db.objectStoreNames.contains('users')) {
        db.createObjectStore('users', { keyPath: 'id' });
      }
    };
    request.onsuccess = (e) => {
      dbCache = e.target.result;
      resolve(dbCache);
    };
    request.onerror = (e) => reject(e.target.error);
  });
}

For enterprise-scale applications, a thin promise wrapper around the native API standardizes async patterns, enforces type safety, and reduces boilerplate across engineering teams. Popular open-source wrappers expose a convenient tx.done promise that resolves when a transaction commits and rejects when it aborts, which removes most of the manual event plumbing. The principle matters more than any one library: never let raw IDBRequest callbacks leak past a single function boundary.

Connection Pooling, Idle Timeouts & Graceful Degradation

Opening a database connection is expensive. Cache the resolved IDBDatabase instance and reuse it across operations. Listen for onversionchange and onclose events to handle concurrent tab upgrades or browser-initiated connection termination gracefully. A versionchange event fires in an old tab when a newer tab requests a higher database version; if the old tab does not close its connection, the upgrade is blocked, so the correct response is to close immediately and prompt a reload.

function wireConnectionLifecycle(db: IDBDatabase, onLost: () => void): void {
  // Another tab is upgrading the schema — release our hold so it can proceed.
  db.onversionchange = () => {
    db.close();
    onLost();
  };
  // The browser terminated the connection (e.g. disk pressure, crash recovery).
  db.onclose = () => {
    onLost();
  };
}

Implement an idle timeout strategy: if no operations occur within a defined window, nullify dbCache and allow the browser to reclaim resources. Always wrap connection logic in a try/catch boundary and implement exponential backoff for transient AbortError or QuotaExceededError states. When you coordinate that backoff across tabs so only one tab retries at a time, the Web Locks API for Cross-Tab Coordination provides the mutual-exclusion primitive.

Storage API Fallbacks & Feature Detection

Offline-first apps must degrade gracefully when IndexedDB is unavailable due to private browsing, enterprise policy restrictions, or quota exhaustion. Implement a strict routing chain that verifies feature support, attempts IndexedDB, and falls back to sessionStorage or in-memory state with clear user messaging. The trade-offs between these synchronous fallbacks are covered in localStorage vs sessionStorage.

async function persistState(key, value) {
  try {
    const db = await getDB();
    await new Promise((resolve, reject) => {
      const tx = db.transaction('state', 'readwrite');
      tx.objectStore('state').put({ key, value, ts: Date.now() });
      tx.oncomplete = () => resolve();
      tx.onerror = () => reject(tx.error);
    });
  } catch (err) {
    if (err.name === 'QuotaExceededError' || err.name === 'InvalidStateError') {
      console.warn('IndexedDB unavailable. Falling back to sessionStorage.');
      try {
        sessionStorage.setItem(key, JSON.stringify(value));
      } catch (e) {
        throw new Error('All persistent storage mechanisms exhausted.');
      }
    } else {
      throw err;
    }
  }
}

3. Transaction Management & Concurrency Control

Scope Isolation (readonly, readwrite) & Auto-Commit Mechanics

IndexedDB transactions are strictly scoped. A readwrite transaction locks its targeted object stores, blocking concurrent readwrite access but allowing readonly operations. Transactions auto-commit when the microtask queue drains and no pending requests remain. Leaving a transaction open without completing requests causes TransactionInactiveError on subsequent calls. Always scope transactions to the minimum required object stores and complete all requests before the event loop yields. The precise timing of this auto-commit window is the source of one of the API’s most notorious footguns, dissected in The IndexedDB Transaction Auto-Commit Timing Bug.

Handling TransactionInactiveError & Exponential Backoff

TransactionInactiveError typically occurs when asynchronous operations (e.g., fetch, setTimeout) interrupt the synchronous transaction lifecycle. To resolve this, batch all database operations within the same synchronous tick, or chain subsequent requests via onsuccess callbacks before the event loop yields. When implementing sync engines, wrap transaction execution in a retry loop with exponential backoff to handle transient locks or browser throttling. For deep dives into isolation levels, deadlock prevention, and commit boundaries, review IndexedDB Transaction Management; the specific case of two transactions waiting on each other’s stores is unpacked in Handling Deadlocks in IndexedDB Read/Write Transactions.

async function withRetry<T>(fn: () => Promise<T>, max = 4): Promise<T> {
  let attempt = 0;
  for (;;) {
    try {
      return await fn();
    } catch (err) {
      const name = (err as DOMException).name;
      const transient = name === 'AbortError' || name === 'TransactionInactiveError';
      if (!transient || attempt >= max) throw err;
      const delay = 2 ** attempt * 50 + Math.random() * 50; // jittered backoff
      await new Promise((r) => setTimeout(r, delay));
      attempt++;
    }
  }
}

Bulk Writes vs. Single-Record Atomicity

Atomicity applies at the transaction level, not the individual record level. Bulk operations (store.put(), store.add()) within a single transaction guarantee all-or-nothing persistence. However, loading massive arrays into memory before insertion triggers OOM crashes on low-end mobile devices. Chunk payloads into batches of 500–1000 records, execute them within discrete transactions, and yield to the main thread using requestIdleCallback or setTimeout to maintain PWA responsiveness. Monitor transaction duration via your browser’s performance profiler; transactions exceeding 100 ms risk UI jank on constrained hardware.

async function bulkInsert(db: IDBDatabase, store: string, records: unknown[]): Promise<void> {
  const CHUNK = 800;
  for (let i = 0; i < records.length; i += CHUNK) {
    const slice = records.slice(i, i + CHUNK);
    await new Promise<void>((resolve, reject) => {
      const tx = db.transaction(store, 'readwrite');
      const os = tx.objectStore(store);
      for (const rec of slice) os.put(rec);
      tx.oncomplete = () => resolve();
      tx.onerror = () => reject(tx.error);
    });
    // Yield so the main thread can paint between chunks.
    await new Promise((r) => setTimeout(r, 0));
  }
}

4. Indexing, Query Optimization & Cursor Patterns

Compound, Multi-Entry & Unique Index Architectures

Indexes accelerate query execution but increase write overhead and storage footprint. Single-column indexes suffice for basic lookups, but complex filtering requires compound indexes (e.g., [category, timestamp]). Multi-entry indexes ({ multiEntry: true }) flatten array values into individual index entries, enabling efficient IN-style queries. Unique indexes enforce data integrity but throw ConstraintError on duplicates. When designing index cardinality and evaluating query selectivity, reference Indexing Strategies for Fast Queries to optimize execution plans and minimize full-store scans. The mechanics of designing a multi-field compound index — and why key order matters — are walked through in Creating Compound Indexes for Multi-Field Filtering.

// Created inside onupgradeneeded. A compound index on [status, createdAt]
// lets you query "all pending orders, newest first" with a single key range.
function buildOrderIndexes(store: IDBObjectStore): void {
  store.createIndex('byStatusDate', ['status', 'createdAt'], { unique: false });
  store.createIndex('byTags', 'tags', { multiEntry: true }); // tags is an array
  store.createIndex('bySku', 'sku', { unique: true });       // enforce uniqueness
}

Cursor Iteration, Memory Footprint & Range Queries

Cursors stream records sequentially, preventing memory exhaustion when querying large datasets. Use IDBKeyRange to bound iterations (IDBKeyRange.bound(lower, upper), IDBKeyRange.only()). Avoid loading entire object stores into arrays; instead, process chunks asynchronously and yield control to the event loop. Debug cursor performance by monitoring heap snapshots during iteration and confirming that the cursor walks an index rather than scanning the full store. For execution plan tuning and memory management best practices during cursor lifecycle operations, see Query Optimization & Cursors.

// Walk only the "pending" orders using the compound index, newest first,
// without ever holding the full result set in memory.
function streamPendingOrders(db: IDBDatabase, onRow: (v: unknown) => void): Promise<void> {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('orders', 'readonly');
    const idx = tx.objectStore('orders').index('byStatusDate');
    const range = IDBKeyRange.bound(['pending', 0], ['pending', Date.now()]);
    const req = idx.openCursor(range, 'prev'); // descending by createdAt
    req.onsuccess = () => {
      const cursor = req.result;
      if (!cursor) return; // tx.oncomplete handles resolve
      onRow(cursor.value);
      cursor.continue();
    };
    req.onerror = () => reject(req.error);
    tx.oncomplete = () => resolve();
  });
}

Pagination, Infinite Scroll & Large Dataset Handling

Offset-based pagination (skip/limit) is inefficient in IndexedDB due to sequential cursor traversal. Implement keyset pagination by tracking the last processed primary key or index value and using IDBKeyRange.lowerBound(lastKey, true) to resume iteration. This approach maintains predictable lookup complexity regardless of dataset size. Combine keyset navigation with virtualized list rendering to ensure smooth infinite scroll experiences. The full keyset pattern, including how to handle ties on a non-unique index, is detailed in Paginating Large IndexedDB Result Sets with Cursors.

5. Storing Blobs, Files & Binary Payloads

IndexedDB is the right home for large binary data — images, audio, PDFs, model weights — because it stores Blob and File objects natively through the structured clone algorithm, without the ~33% size inflation that base64 encoding imposes. You hand the store a Blob and the engine persists the bytes directly; on read you get a Blob back that you can turn into an object URL or stream. Avoid serializing binary data to a base64 string first: it bloats storage, slows writes, and forces a synchronous encode/decode on the main thread.

// Persist a File picked from an <input> and retrieve it as a displayable URL.
async function saveFile(db: IDBDatabase, id: string, file: File): Promise<void> {
  await new Promise<void>((resolve, reject) => {
    const tx = db.transaction('files', 'readwrite');
    tx.objectStore('files').put({ id, blob: file, type: file.type, name: file.name });
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });
}

async function loadFileUrl(db: IDBDatabase, id: string): Promise<string> {
  const rec = await new Promise<{ blob: Blob }>((resolve, reject) => {
    const req = db.transaction('files', 'readonly').objectStore('files').get(id);
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
  return URL.createObjectURL(rec.blob); // remember to revokeObjectURL later
}

For the complete treatment of chunking very large files, streaming, and the storage and performance trade-offs, see Storing Blobs & Files in IndexedDB, and for the specific image-encoding decision read Storing Images as Blobs vs Base64 in IndexedDB. When files grow into the hundreds of megabytes and you need random-access reads or true streaming writes, the Origin Private File System may be a better fit — that comparison lives in the fundamentals area and is worth weighing before you commit a multi-gigabyte media cache to object stores.

Quick reference

Decision Default choice When to deviate
Connection handling Cache one IDBDatabase, reuse it Nullify on idle timeout to free resources
Schema change Bump version, mutate in onupgradeneeded Never mutate stores outside the upgrade event
Transaction scope Smallest set of stores, readonly when possible Widen only for genuine multi-store atomicity
Bulk write Chunk 500–1000 records, yield between batches Single transaction only for small, atomic sets
Large read Cursor + IDBKeyRange, keyset pagination getAll() only for small, bounded result sets
Binary data Store Blob/File directly base64 only for tiny inline assets

Engineering Anti-Patterns & Debugging Checklist

Debugging Workflow: Use your browser’s Application or Storage developer panel to inspect object stores, manually trigger onversionchange events, and simulate quota exhaustion. In Safari, verify private-mode behavior and the ITP eviction window. Always validate concurrent tab writes using headless automation to surface race conditions before production deployment.

Frequently Asked Questions

How much data can IndexedDB actually hold?

It depends on the engine and available disk. Chromium allows a best-effort allocation up to roughly 60% of free disk space per storage group, Firefox enforces a per-group quota, and Safari is the most conservative, evicting data after 7 days of inactivity for non-installed sites. Always check navigator.storage.estimate() at runtime rather than assuming a fixed limit, and see Storage Quotas & Eviction Policies for the full mechanics.

Why do I keep getting TransactionInactiveError?

A transaction auto-commits as soon as the microtask queue drains with no pending requests. If you await a network call or a timer inside a transaction, it commits before your next request runs, and that request throws TransactionInactiveError. Batch all reads and writes synchronously within the transaction, or chain them through onsuccess callbacks. See IndexedDB Transaction Management.

Should I store images as Blobs or base64 strings?

Store the Blob directly. IndexedDB persists binary objects natively via structured clone, while base64 inflates the payload by about a third and forces a main-thread encode/decode. The detailed comparison is in Storing Images as Blobs vs Base64 in IndexedDB.

How do I migrate a schema without losing user data?

Bump the database version and perform every structural change inside the onupgradeneeded handler, using fall-through version checks so a client on any older version applies each migration exactly once. Transform existing records inside the same upgrade transaction. The full procedure is in Database Schema Migrations.

What's the fastest way to paginate a large object store?

Use keyset pagination, not offset/limit. Track the last key you read and resume with IDBKeyRange.lowerBound(lastKey, true) against an index, so each page is a bounded cursor walk rather than a re-scan from the start. See Query Optimization & Cursors for the pattern and its edge cases.

Related