Step-by-Step IndexedDB Version Upgrade Migration
For offline-first applications and PWAs, schema changes are inevitable. However, deploying new object stores or indexes without a robust migration strategy triggers VersionError or InvalidStateError during indexedDB.open(). This walkthrough sits under the broader Database Schema Migrations guide and delivers a production-safe, step-by-step approach to handling incremental database upgrades without data loss, transaction locks, or main-thread blocking.
Problem Statement & Symptoms
When returning users load an updated app, mismatched database versions cause immediate crashes or blank states. The underlying storage engine fails to reconcile schema differences, breaking offline state persistence and forcing manual cache clears. Before modifying schemas, engineers should understand the core mechanics described in the parent IndexedDB Architecture & Advanced Patterns guide to avoid irreversible storage corruption.
Common Symptoms:
VersionErrorthrown whenindexedDB.open()requests a version lower than the existing database.InvalidStateErrorduringcreateObjectStoreorcreateIndexcalls outside the upgrade transaction.- Silent data loss when legacy clients skip incremental migration steps.
Root Cause Analysis
IndexedDB enforces strictly monotonically increasing version numbers. Per the specification, opening a database with a higher version triggers the onupgradeneeded event, but the API does not automatically execute incremental migration logic — it simply hands you one versionchange transaction and expects your code to reconcile every intermediate schema. Critical failure points include:
- Overwriting existing stores instead of checking
event.oldVersion. - Skipping version thresholds, causing partial or duplicate schema creation.
- Uncaught exceptions inside
onupgradeneeded, which abort theversionchangetransaction and revert the database to its previous version. - Calling
db.transaction()insideonupgradeneeded— all schema operations must use the implicit versionchange transaction available asevent.target.transaction. - An open connection in another tab blocking the upgrade, which fires the request’s
blockedevent and leaves the migration stalled until that tab closes its connection.
Step-by-Step Migration Strategy
- Increment the version parameter in
indexedDB.open(dbName, targetVersion). - Attach
onupgradeneededto intercept theversionchangetransaction. - Implement conditional migration blocks using
event.oldVersionto handle incremental jumps (e.g.,v1→v2,v2→v3). Always useif (oldVersion < N)so a multi-version jump falls through every block in order. - Execute all schema operations using the implicit transaction (
event.target.transaction).createObjectStoreandcreateIndexmust complete before the transaction commits. - Apply data transformation logic after
onsuccess(not insideonupgradeneeded) for large datasets to avoid blocking the versionchange transaction.
For comprehensive workflows on handling complex schema evolution and backward compatibility, refer to the Database Schema Migrations guide. To serialize the upgrade across multiple open tabs, the Web Locks API for Cross-Tab Coordination guide shows how to acquire a named lock so only one tab drives the migration.
Production-Ready Implementation
The following async wrapper handles quota checks, explicit transaction aborts, version verification, and the blocked event.
/**
* Safely opens an IndexedDB database and executes incremental migrations.
* @param {string} dbName - Database identifier
* @param {number} targetVersion - Monotonically increasing schema version
* @returns {Promise<IDBDatabase>} Resolves with the upgraded database instance
*/
async function upgradeDatabase(dbName, targetVersion) {
// Pre-flight quota check to prevent QuotaExceededError during migration
if ('storage' in navigator && 'estimate' in navigator.storage) {
const { quota = 0, usage = 0 } = await navigator.storage.estimate();
if (quota > 0 && usage / quota > 0.9) {
console.warn('Storage quota nearing limit. Large migrations may fail.');
}
}
return new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, targetVersion);
request.onerror = (event) => {
const error = event.target.error;
reject(new Error(`Open failed: ${error.name} - ${error.message}`));
};
// Another tab still holds an older connection open.
request.onblocked = () => {
reject(new Error('Upgrade blocked: close other tabs holding this database.'));
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Always use the implicit versionchange transaction — never call db.transaction() here.
const tx = event.target.transaction;
const oldVersion = event.oldVersion;
try {
// v0 -> v1: Create base store (oldVersion is 0 for brand-new databases)
if (oldVersion < 1) {
db.createObjectStore('sessions', { keyPath: 'id' });
}
// v1 -> v2: Add secondary index using the implicit tx
if (oldVersion < 2) {
const store = tx.objectStore('sessions');
store.createIndex('userId', 'userId', { unique: false });
}
// Add future version blocks here: if (oldVersion < 3) { ... }
} catch (err) {
// Critical: Explicitly abort to prevent partial/corrupted state
tx.abort();
reject(new Error(`Migration aborted at v${oldVersion + 1}: ${err.message}`));
}
};
request.onsuccess = () => {
const db = request.result;
// If a newer version is requested elsewhere, close so we don't block it.
db.onversionchange = () => db.close();
// Verify successful upgrade before resolving
if (db.version === targetVersion) {
resolve(db);
} else {
db.close();
reject(
new Error(`Version mismatch: expected ${targetVersion}, got ${db.version}`)
);
}
};
});
}
Validation & Testing Protocol
Deploying schema changes requires strict validation before production rollout:
- Version Assertion: Confirm
db.version === targetVersionafteronsuccess. - Schema Verification: Execute
db.objectStoreNames.contains('sessions')and asserttruepost-migration. - Index Query Test: Open a
readonlytransaction and run.index('userId').get(someId)to verify the migrated index returns expected results. - Legacy Simulation: In Chrome DevTools (
Application > Storage > IndexedDB), delete the database, then callupgradeDatabase()witholdVersion1 to verify that conditionalif (oldVersion < 2)blocks fire correctly. - Performance Monitoring: Wrap
onupgradeneededlogic withperformance.now()markers. Ensure migration completes in<50 msfor small datasets. For large record transformations, run backfills afteronsuccessusingIDBCursorto prevent main-thread jank.
Edge Cases & Fallback Approaches
A few situations break the happy path and need explicit handling:
- Downgrade attempts. If a user runs an older build after a newer one upgraded the database,
indexedDB.open()with the lower version throwsVersionError. There is no in-place downgrade. The pragmatic fallback is to detect the higher existing version, surface a “please refresh to the latest version” message, and avoid silently deleting their data. - Blocked upgrades from stale tabs. If
onblockedfires, the upgrade cannot proceed until the other connection closes. Settingdb.onversionchange = () => db.close()in every tab (as shown above) lets the upgrading tab proceed automatically. For a hard guarantee that exactly one tab runs the migration, acquire a Web Lock first. - Aborted backfill leaving partial data. When a post-
onsuccessbackfill is interrupted, the schema is current but some records are stale. Stamp each migrated record with aschemaVersionfield so the app can detect and resume the backfill on the next launch. - A genuinely corrupted database. If repeated opens fail with
InvalidStateErrororUnknownErrorthat no patch resolves, the recovery path is to delete and rebuild. The Recovering from a Failed IndexedDB Version Upgrade guide details how to do this without losing recoverable data.
Frequently Asked Questions
Why does indexedDB.open() throw VersionError?
VersionError means you requested a version lower than the one already stored. Version numbers are strictly monotonically increasing and cannot go backward. This usually happens when an older build runs after a newer one upgraded the database. Detect the higher stored version and prompt the user to load the latest app build rather than attempting to downgrade.
Why must schema changes use event.target.transaction?
createObjectStore, createIndex, and their deletion counterparts are only valid inside the single versionchange transaction the browser opens for you during onupgradeneeded. Calling db.transaction() there creates a conflicting transaction and throws InvalidStateError. Read the implicit transaction from event.target.transaction and use it for every structural change.
How do I migrate large datasets without freezing the UI?
Do not run heavy transforms inside onupgradeneeded — the versionchange transaction blocks all other access while it runs. Instead, perform structural changes in the upgrade, then run the data backfill after onsuccess in a separate readwrite transaction using a cursor. Stamp each record with a schema version so an interrupted backfill can resume.
Related
- Database Schema Migrations — the parent guide covering versioning strategy, incremental patches, and backfills.
- Recovering from a Failed IndexedDB Version Upgrade — how to detect and rebuild after an aborted migration.
- Web Locks API for Cross-Tab Coordination — serialize the upgrade so a single tab drives it.
- IndexedDB Transaction Management — the transaction rules that govern the versionchange lifecycle.
- IndexedDB Architecture & Advanced Patterns — the foundational storage model behind every migration.