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.
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
- Ignoring transaction auto-close: Allowing the event loop to yield (e.g., via
await fetch()) inside a transaction causesTransactionInactiveError; batch all IndexedDB requests before anyawait. - Blocking the main thread with synchronous loops: Heavy cursor iterations without
requestIdleCallbackor Web Worker offloading degrade PWA responsiveness and trigger watchdog timeouts on mobile. - Hardcoding schema versions: Skipping incremental
onupgradeneededlogic breaks state persistence across app updates and corrupts existing user data. - Over-fetching with cursors: Iterating entire object stores into memory instead of using bounded ranges or keyset pagination causes OOM crashes on low-RAM devices.
- Neglecting quota monitoring: Omitting
navigator.storage.estimate()checks leads to silentQuotaExceededErrorfailures and desynchronized offline queues. - base64-encoding binaries: Inflating blobs by ~33% to fit them into string fields wastes quota and stalls the main thread; store the
Blobitself.
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
- Database Schema Migrations — zero-downtime version upgrades and data transformation pipelines.
- IndexedDB Transaction Management — isolation, auto-commit timing, deadlocks, and retry strategy.
- Indexing Strategies for Fast Queries — compound, multi-entry, and unique index design.
- Query Optimization & Cursors — bounded ranges, keyset pagination, and memory-safe iteration.
- Storing Blobs & Files in IndexedDB — persisting binary payloads efficiently and at scale.
- Offline Sync Strategies & Background Workflows — using IndexedDB as the local source of truth behind a sync queue.