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:

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:

Step-by-Step Migration Strategy

  1. Increment the version parameter in indexedDB.open(dbName, targetVersion).
  2. Attach onupgradeneeded to intercept the versionchange transaction.
  3. Implement conditional migration blocks using event.oldVersion to handle incremental jumps (e.g., v1→v2, v2→v3).
  4. Execute all schema operations using the implicit transaction (event.target.transaction). createObjectStore and createIndex must complete before the transaction commits.
  5. Apply data transformation logic after onsuccess (not inside onupgradeneeded) for large datasets to avoid blocking the versionchange transaction.

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.

/**
 * 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}`));
    };

    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;
      // 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:

  1. Version Assertion: Confirm db.version === targetVersion after onsuccess.
  2. Schema Verification: Execute db.objectStoreNames.contains('sessions') and assert true post-migration.
  3. Index Query Test: Open a readonly transaction and run .index('userId').get(someId) to verify the migrated index returns expected results.
  4. Legacy Simulation: In Chrome DevTools (Application > Storage > IndexedDB), delete the database, then call upgradeDatabase() with oldVersion 1 to verify that conditional if (oldVersion < 2) blocks fire correctly.
  5. Performance Monitoring: Wrap onupgradeneeded logic with performance.now() markers. Ensure migration completes in <50 ms for small datasets. For large record transformations, run backfills after onsuccess using IDBCursor to prevent main-thread jank.