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 guide 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 must understand the core mechanics of IndexedDB Architecture & Advanced Patterns 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. Opening a database with a higher version triggers the onupgradeneeded event, but the API does not automatically execute incremental migration logic. 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 immediately abort theversionchangetransaction and permanently lock the database until the version is incremented again.
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). - Execute schema operations synchronously within the upgrade event scope.
createObjectStoreandcreateIndexmust complete before the transaction commits. - Apply data transformation logic immediately after schema creation to preserve existing records. For comprehensive workflows on handling complex schema evolution and backward compatibility, refer to established Database Schema Migrations patterns.
Production-Ready Implementation
The following async wrapper handles quota checks, explicit transaction aborts, and version verification. It is designed for direct integration into service workers or app initialization routines.
/**
* 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) {
return new Promise((resolve, reject) => {
// Pre-flight quota check to prevent QuotaExceededError during migration
if ('storage' in navigator && 'estimate' in navigator.storage) {
navigator.storage.estimate().then(({ quota, usage }) => {
if (usage > quota * 0.9) {
console.warn('Storage quota nearing limit. Large migrations may fail.');
}
});
}
const request = indexedDB.open(dbName, targetVersion);
request.onerror = (event) => {
const error = event.target.error;
reject(new Error(`Open failed: ${error.name} - ${error.message}`));
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
const oldVersion = event.oldVersion;
try {
// v1 -> v2: Create base store
if (oldVersion < 2) {
db.createObjectStore('sessions', { keyPath: 'id' });
}
// v2 -> v3: Add secondary index
if (oldVersion < 3) {
const tx = event.target.transaction;
const store = tx.objectStore('sessions');
store.createIndex('userId', 'userId', { unique: false });
}
// Add future version blocks here: if (oldVersion < 4) { ... }
} catch (err) {
// Critical: Explicitly abort to prevent partial/corrupted state
event.target.transaction.abort();
reject(new Error(`Migration aborted at v${oldVersion + 1}: ${err.message}`));
}
};
request.onsuccess = () => {
const db = request.result;
// Verify successful upgrade before resolving
if (db.version === targetVersion) {
resolve(db);
} else {
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 === targetVersionresolves without throwing. - Schema Verification: Execute
db.objectStoreNames.contains('sessions')and asserttruepost-migration. - Index Query Test: Open a
readonlytransaction and run a.get()or.index()query to verify migrated indexes return expected cursors. - Legacy Simulation: In Chrome DevTools (
Application > Storage > IndexedDB), delete the database, then manually triggerupgradeDatabase()witholdVersionsimulated via version1in a controlled test environment. - Performance Monitoring: Wrap
onupgradeneededlogic withperformance.now()markers. Ensure migration completes in<50msfor small datasets. For large record transformations, chunk operations usingIDBCursorto prevent main-thread jank.