Fix: IndexedDB Not Working — Transaction Inactive, Upgrade Blocked, or Store Not Found
Part of: React & Frontend Errors
Quick Answer
How to fix IndexedDB issues — transaction lifecycle, version upgrades, blocked events, cursor iteration, IDBKeyRange queries, and using idb wrapper library to avoid callback hell.
The Problem
An IndexedDB write fails with TransactionInactiveError:
const request = indexedDB.open('mydb', 1);
request.onsuccess = (e) => {
const db = e.target.result;
setTimeout(() => {
const tx = db.transaction('users', 'readwrite');
tx.objectStore('users').add({ id: 1, name: 'Alice' });
// DOMException: The transaction has finished.
}, 100);
};Or the database upgrade is blocked and onupgradeneeded never fires:
const request = indexedDB.open('mydb', 2);
request.onupgradeneeded = (e) => { /* Never called */ };
request.onsuccess = (e) => { /* Also never called */ };
// request.onblocked fires insteadOr an object store doesn’t exist despite being created in onupgradeneeded:
const tx = db.transaction('products', 'readonly');
// DOMException: No objectStore named products in this transactionWhy This Happens
IndexedDB has a unique transactional model that trips up most developers:
- Transactions auto-commit when idle — a transaction is automatically committed when no pending requests remain in the current event loop tick. You cannot resume a transaction after an
await, asetTimeout, or any async gap. - Schema changes only happen in
onupgradeneeded— object stores and indexes can only be created or deleted during a version upgrade. Attempting to create a store in a regular transaction throws. - Version upgrades are blocked by other open connections — if another tab has the same database open at the old version,
onupgradeneededwon’t fire until all other connections close (or callclose()). This causes theblockedevent. - Store names must match exactly —
db.transaction('Users', 'readonly')anddb.transaction('users', 'readonly')are different. The store name is case-sensitive.
The deeper reason IndexedDB feels hostile is that it was designed before Promises existed in the platform. Every operation is a request object that fires onsuccess or onerror asynchronously, but the surrounding transaction commits as soon as the event loop sees no more pending requests on it. That works fine if every step is chained through onsuccess callbacks, but breaks the instant you bridge to modern async code with await, Promise.then, or a microtask boundary. The transaction sees a tick with no pending requests, commits itself, and your next operation fails with TransactionInactiveError. Wrapper libraries like idb paper over this by deferring microtasks until the transaction’s pending request count drops to zero, but you still cannot await arbitrary async work (a fetch, a setTimeout) inside a transaction.
How Other Tools Handle This
IndexedDB is the only structured client-side storage built into browsers, so most “alternatives” are wrappers or distinct paradigms.
Raw IndexedDB vs Dexie vs idb vs LocalForage vs PouchDB. Raw IndexedDB gives you full control and zero abstraction tax but exposes the callback/transaction model directly. idb (Jake Archibald, ~1.2KB gzipped) is the thinnest wrapper — it converts request events to Promises while preserving the underlying transaction model, so the mental model stays identical and the bundle stays small. Dexie adds a real query API (db.users.where('age').above(18).toArray()), schema versioning helpers, and live queries, at the cost of ~22KB gzipped. LocalForage abstracts over IndexedDB, WebSQL, and localStorage with a key-value API — useful if you need to ship to browsers without IndexedDB but lose all relational capabilities. PouchDB is a different beast: a full CouchDB-compatible database with sync-with-server built in, plus conflict resolution and revision history. Choose by the question you cannot avoid: if you need offline sync, PouchDB; if you need queries, Dexie; if you need minimal overhead, idb; if you need legacy fallback, LocalForage; if you need bytes, raw IndexedDB.
Sync patterns. PouchDB syncs bidirectionally with a CouchDB-protocol server out of the box. Firebase Firestore offers offline persistence over IndexedDB with conflict resolution handled by the server’s last-write-wins policy. Replicache and Triplit treat IndexedDB as a local cache with deterministic mutators that replay against the server. RxDB layers reactive queries and replication on top of IndexedDB (or other adapters) with RxJS observables. None of these are drop-in for plain IndexedDB — choosing sync means choosing a server-side protocol.
Transaction models. IndexedDB transactions are scoped to one or more object stores up front and run with snapshot isolation (no dirty reads, no phantom reads), but they auto-commit on idle, which is unique. SQLite-on-Wasm (via wa-sqlite or @sqlite.org/sqlite-wasm) gives true SQL transactions with explicit BEGIN/COMMIT and survives await cleanly — at the cost of shipping a Wasm runtime and managing the OPFS storage handle. The OPFS-backed SQLite path is the closest browsers come to a traditional relational database and is what tools like Notion’s offline mode and Linear’s local cache use under the hood. For applications doing complex joins or aggregations, SQLite-on-Wasm has overtaken raw IndexedDB; for simple key-value or single-store workloads, IndexedDB remains lighter.
Storage quotas across browsers. Chrome allocates up to ~60% of free disk space per origin group; Firefox allocates up to 50% with a 10GB soft cap; Safari historically capped at 1GB without a prompt and aggressively evicts on storage pressure. Always call navigator.storage.persist() to request persistence and navigator.storage.estimate() to check headroom before large writes — the storage budget is not portable across browsers.
In Production: Incident Lens
The most common IndexedDB production incident is a hung schema upgrade. A user has the app open in two tabs, you ship a new version that bumps the database version, and the second tab fires onblocked instead of onupgradeneeded because the first tab has the database open. The first tab never sees onversionchange because the developer forgot to register a handler, so it never calls close(), and the upgrade hangs forever. Always register onversionchange on every database connection and have it call db.close() plus optionally reload the page — this lets a peer tab unblock the upgrade.
The second incident pattern is silent eviction on iOS Safari. Mobile Safari clears IndexedDB without warning when the device is under storage pressure, and your “we sync from local cache on startup” code returns an empty store. The user loses unsynced edits with no error. The defense is to treat IndexedDB as a cache, not a source of truth — sync writes to the server within a few seconds and surface a “saving…” indicator until the server acknowledges. Use navigator.storage.persist() to request persistent storage, but understand that Safari grants it sparingly and the user prompt rarely appears.
The third recurring incident is performance collapse from doing many one-record transactions. Each db.put('users', user) opens a new transaction, which has measurable setup cost. A loop doing 10,000 individual puts can take 30+ seconds on a mid-range phone and freeze the UI thread for several seconds at a time. Batching every write within a single transaction — const tx = db.transaction('users', 'readwrite'); await Promise.all([...users.map(u => tx.store.put(u)), tx.done]); — drops the same workload to under a second. Profile with the Performance panel before shipping any bulk import code.
Fix 1: Never Use Transactions Across Async Gaps
Keep all transaction operations synchronous or chained through IDB request events — never through await or setTimeout:
// WRONG — transaction is committed before the await resolves
const tx = db.transaction('users', 'readwrite');
const store = tx.objectStore('users');
const userRequest = store.get(1);
userRequest.onsuccess = async () => {
const user = userRequest.result;
await someAsyncOperation(); // Transaction auto-commits here!
store.put({ ...user, name: 'Bob' }); // TransactionInactiveError
};
// CORRECT — chain operations through IDB request events, no async gaps
const tx = db.transaction('users', 'readwrite');
const store = tx.objectStore('users');
const getRequest = store.get(1);
getRequest.onsuccess = () => {
const user = getRequest.result;
const putRequest = store.put({ ...user, name: 'Bob' });
putRequest.onsuccess = () => {
console.log('Updated successfully');
};
};
tx.oncomplete = () => console.log('Transaction committed');
tx.onerror = () => console.error('Transaction failed:', tx.error);Best practice: use the idb library to wrap IndexedDB with Promises:
import { openDB } from 'idb';
const db = await openDB('mydb', 1, {
upgrade(db) {
db.createObjectStore('users', { keyPath: 'id', autoIncrement: true });
}
});
// idb keeps transactions alive across awaits within the same tick
// but you still can't span real async operations
async function updateUser(id, newName) {
const tx = db.transaction('users', 'readwrite');
const user = await tx.store.get(id); // OK — idb chains these
await tx.store.put({ ...user, name: newName });
await tx.done; // Wait for commit
}
// Clean reads and writes
const user = await db.get('users', 1);
await db.put('users', { ...user, name: 'Bob' });
await db.delete('users', 2);
const all = await db.getAll('users');Fix 2: Set Up Schema in onupgradeneeded
All object store and index creation must happen inside onupgradeneeded:
const request = indexedDB.open('mydb', 1);
request.onupgradeneeded = (e) => {
const db = e.target.result;
const oldVersion = e.oldVersion;
// Run migration steps based on version
if (oldVersion < 1) {
// Version 1: create initial schema
const userStore = db.createObjectStore('users', {
keyPath: 'id',
autoIncrement: true
});
userStore.createIndex('by_email', 'email', { unique: true });
userStore.createIndex('by_name', 'name', { unique: false });
}
if (oldVersion < 2) {
// Version 2: add products store
const productStore = db.createObjectStore('products', { keyPath: 'sku' });
productStore.createIndex('by_category', 'category');
productStore.createIndex('by_price', 'price');
}
if (oldVersion < 3) {
// Version 3: add compound index to existing store
// Access existing store via the upgrade transaction
const userStore = e.target.transaction.objectStore('users');
userStore.createIndex('by_name_email', ['name', 'email'], { unique: true });
}
};
request.onsuccess = (e) => {
const db = e.target.result;
// At this point, all stores from onupgradeneeded are available
};
request.onerror = (e) => {
console.error('Failed to open database:', e.target.error);
};Using idb for migrations:
import { openDB } from 'idb';
const db = await openDB('mydb', 3, {
upgrade(db, oldVersion, newVersion, transaction) {
if (oldVersion < 1) {
const users = db.createObjectStore('users', { keyPath: 'id', autoIncrement: true });
users.createIndex('by_email', 'email', { unique: true });
}
if (oldVersion < 2) {
db.createObjectStore('products', { keyPath: 'sku' });
}
if (oldVersion < 3) {
const users = transaction.objectStore('users');
users.createIndex('by_role', 'role');
}
},
blocked(currentVersion, blockedVersion, event) {
// Old version has open connections — tell user to close other tabs
alert('Please close other tabs with this app open to upgrade the database.');
},
blocking(currentVersion, newVersion, event) {
// This tab is blocking an upgrade in another tab
db.close(); // Close our connection to unblock the upgrade
},
});Fix 3: Handle Version Blocked Events
When another tab has the database open, upgrades are blocked:
const request = indexedDB.open('mydb', 2);
request.onblocked = (e) => {
// Another connection is open and not calling db.close()
console.warn('Database upgrade blocked. Close other tabs.');
// Optional: notify user
document.querySelector('#upgrade-notice').style.display = 'block';
};
request.onupgradeneeded = (e) => {
// Only runs after all blocking connections close
const db = e.target.result;
// ... migration code
};
// In the existing connection — listen for version change requests
const existingDb = /* ... */;
existingDb.onversionchange = () => {
// Another tab wants to upgrade — close our connection
existingDb.close();
// Optionally reload the page to pick up the new schema
window.location.reload();
};Fix 4: Query with Indexes and IDBKeyRange
Retrieve specific records efficiently using indexes and key ranges:
// Open database (schema: users store with 'by_email' and 'by_name' indexes)
const db = await openDB('mydb', 1);
// Get by primary key
const user = await db.get('users', 1);
// Get all records
const allUsers = await db.getAll('users');
// Get by index value
const userByEmail = await db.getFromIndex('users', 'by_email', '[email protected]');
// Get all matching an index value
const smiths = await db.getAllFromIndex('users', 'by_name', 'Smith');
// Range queries with IDBKeyRange
const IDBKeyRange = window.IDBKeyRange;
// Users with id between 10 and 20 (inclusive)
const range1 = IDBKeyRange.bound(10, 20);
const batch = await db.getAll('users', range1);
// Users with id > 5 (exclusive lower bound)
const range2 = IDBKeyRange.lowerBound(5, true);
const afterFive = await db.getAll('users', range2, 10); // Limit to 10 results
// Find users by name prefix (e.g., all names starting with 'Al')
const range3 = IDBKeyRange.bound('Al', 'Al\uffff'); // \uffff is a high character
const alNames = await db.getAllFromIndex('users', 'by_name', range3);Cursor-based iteration for large datasets:
import { openDB } from 'idb';
const db = await openDB('mydb', 1);
// Iterate through all users with a cursor
const tx = db.transaction('users', 'readwrite');
let cursor = await tx.store.openCursor();
while (cursor) {
const user = cursor.value;
if (user.inactive) {
await cursor.delete(); // Delete inactive users
} else {
await cursor.update({ ...user, lastChecked: new Date() });
}
cursor = await cursor.continue();
}
await tx.done;
// Iterate an index with a range
const nameTx = db.transaction('users', 'readonly');
const index = nameTx.store.index('by_name');
let nameCursor = await index.openCursor(IDBKeyRange.lowerBound('M'));
const mNames = [];
while (nameCursor) {
mNames.push(nameCursor.value);
nameCursor = await nameCursor.continue();
}Fix 5: Batch Operations for Performance
Single-record operations have high overhead due to transaction setup. Batch writes in a single transaction:
import { openDB } from 'idb';
const db = await openDB('mydb', 1);
// SLOW — one transaction per record
for (const user of users) {
await db.put('users', user); // New transaction each time
}
// FAST — one transaction for all records
async function bulkInsert(users) {
const tx = db.transaction('users', 'readwrite');
await Promise.all([
...users.map(user => tx.store.put(user)),
tx.done
]);
}
// Bulk insert 10,000 records efficiently
await bulkInsert(largeUserArray);
// Clear and repopulate
async function replaceAll(newUsers) {
const tx = db.transaction('users', 'readwrite');
await tx.store.clear();
await Promise.all(newUsers.map(u => tx.store.put(u)));
await tx.done;
}Fix 6: Debug IndexedDB in DevTools
Chrome and Firefox DevTools have built-in IndexedDB viewers:
// Log all data in a store for debugging
async function dumpStore(dbName, storeName) {
const db = await openDB(dbName);
const all = await db.getAll(storeName);
console.table(all);
db.close();
}
// Delete the entire database to start fresh
async function resetDatabase(dbName) {
return new Promise((resolve, reject) => {
const request = indexedDB.deleteDatabase(dbName);
request.onsuccess = resolve;
request.onerror = reject;
request.onblocked = () => console.warn('Delete blocked — close other tabs');
});
}
// Check what databases exist (Chrome 73+)
const databases = await indexedDB.databases();
console.table(databases);
// [{ name: 'mydb', version: 3 }, ...]
// Check current version
const request = indexedDB.open('mydb');
request.onsuccess = (e) => {
console.log('Current version:', e.target.result.version);
e.target.result.close();
};Still Not Working?
Private/incognito mode has storage restrictions — in Safari private mode, IndexedDB throws errors for any write operation (QuotaExceededError or InvalidStateError). Always wrap IndexedDB calls in try/catch and fall back to in-memory storage if needed. Firefox private mode allows IndexedDB but clears it when the window closes.
Storage quota exceeded — browsers allocate IndexedDB storage based on available disk space (typically 20-60% of available disk for the origin group). Large datasets can hit this limit. Use navigator.storage.estimate() to check:
const estimate = await navigator.storage.estimate();
console.log(`Used: ${estimate.usage} bytes`);
console.log(`Available: ${estimate.quota} bytes`);Request persistent storage to avoid eviction under storage pressure:
if (await navigator.storage.persist()) {
console.log('Storage will not be evicted');
}Cross-origin iframes have separate IndexedDB — each origin has its own IndexedDB namespace. Data stored by https://app.example.com is not accessible to an iframe on https://widget.example.com, even within the same parent page.
iOS Safari wipes IndexedDB on low storage — Safari on iOS can delete IndexedDB data without warning when the device storage is low. Don’t use IndexedDB as the sole source of truth for critical data; sync to a server when online.
For related browser storage issues, see Fix: Vite Env Variables Not Working, Fix: JavaScript Unhandled Promise Rejection, Fix: Web Worker Not Working, and Fix: JavaScript TypeError Is Not a Function.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Web Worker Not Working — postMessage Ignored, Cannot Import Module, or Worker Crashes Silently
How to fix Web Worker issues — postMessage data cloning, module workers, error handling, SharedArrayBuffer setup, Comlink, and common reasons workers silently fail.
Fix: AutoAnimate Not Working — Transitions Not Playing, List Items Not Animating, or React State Changes Ignored
How to fix @formkit/auto-animate issues — parent ref setup, React useAutoAnimate hook, Vue directive, animation customization, disabling for specific elements, and framework integration.
Fix: Blurhash Not Working — Placeholder Not Rendering, Encoding Failing, or Colors Wrong
How to fix Blurhash image placeholder issues — encoding with Sharp, decoding in React, canvas rendering, Next.js image placeholders, CSS blur fallback, and performance optimization.
Fix: CodeMirror Not Working — Editor Not Rendering, Extensions Not Loading, or React State Out of Sync
How to fix CodeMirror 6 issues — basic setup, language and theme extensions, React integration, vim mode, collaborative editing, custom keybindings, and read-only mode.