Database Schema Migrations for Offline-First Web Applications

Offline-first applications inevitably outgrow their initial data models as feature sets scale. Unlike server-side relational databases that support hot schema swaps and automated migration runners, client-side storage demands explicit, versioned Database Schema Migrations to prevent silent data corruption during updates. Frontend engineers and PWA developers must architect these transitions around the browser’s strict storage lifecycle, ensuring backward compatibility for users whose service worker caches or app shells haven’t synchronized with the latest deployment.

Versioned Schema Evolution in Browser Storage

Offline-first state persistence relies on deterministic versioning. Every time your application requests a database version higher than the one currently persisted, the browser initiates a structural upgrade sequence. This model exists to protect user data from partial writes or incompatible object shapes. Before attempting structural modifications, teams should internalize the foundational mechanics outlined in IndexedDB Architecture & Advanced Patterns, particularly how browser eviction policies and storage quotas interact with long-lived client databases. Migrations must be planned defensively, assuming users may open the application after skipping multiple releases or while operating in restricted network conditions.

The onupgradeneeded Event Lifecycle

Schema modifications exclusively occur within the onupgradeneeded callback, which fires synchronously when the requested version exceeds the stored version. This event operates inside a dedicated version-change transaction that blocks all other database access until completion. All calls to createObjectStore(), deleteObjectStore(), createIndex(), and deleteIndex() must use the implicit transaction provided by event.target.transaction — you cannot and must not open a new db.transaction() inside onupgradeneeded. Proper IndexedDB Transaction Management ensures that object store creation, deletion, and index modifications execute atomically. If the upgrade transaction throws an unhandled exception or is explicitly aborted, the entire migration rolls back to the previous schema state.

Incremental Patch Strategies for Version Jumps

Users frequently skip multiple app releases, triggering significant version jumps (e.g., migrating directly from v1 to v4). Production-grade migrations must apply sequential patches rather than assuming a direct delta between the current and target versions. Each patch should validate the oldVersion parameter and conditionally execute structural changes using strict inequality checks (if (oldVersion < 2)). When introducing new indexes during these patches, align them with proven Indexing Strategies for Fast Queries to prevent cursor degradation and memory bloat during subsequent offline reads. Avoid creating indexes on high-cardinality or frequently mutated fields unless query performance explicitly demands it.

Data Transformation and Backfill Workflows

Structural changes rarely stop at schema definition; they often require migrating existing records to new object shapes. This involves opening cursors, reading legacy payloads, applying transformation logic, and writing updated records back to the store. For a complete implementation reference covering cursor iteration, error handling, and atomic writes, consult the Step-by-step IndexedDB version upgrade migration documentation. Always batch writes and yield to the main thread periodically to avoid memory pressure on low-end mobile devices. Heavy synchronous transformations inside the upgrade transaction will trigger TransactionInactiveError or browser-level timeouts.


Production-Ready Migration Implementation

The following examples demonstrate a robust, TypeScript-compliant approach to handling version upgrades, quota constraints, and asynchronous data backfills.

Incremental Version Upgrade Handler

The key rule: all DDL operations use event.target.transaction (the implicit versionchange transaction). Do not call db.transaction() inside onupgradeneeded.

const DB_NAME = 'app_state';
const TARGET_DB_VERSION = 3;

export function initDatabase(): Promise<IDBDatabase> {
  return new Promise((resolve, reject) => {
    const dbRequest = indexedDB.open(DB_NAME, TARGET_DB_VERSION);

    dbRequest.onupgradeneeded = (event: IDBVersionChangeEvent) => {
      const db = (event.target as IDBOpenDBRequest).result;
      // The implicit versionchange transaction — use this for all schema work.
      const tx = (event.target as IDBOpenDBRequest).transaction!;
      const oldVersion = event.oldVersion || 0;

      // Patch v1: Initial schema creation
      if (oldVersion < 1) {
        db.createObjectStore('users', { keyPath: 'id' });
      }

      // Patch v2: Add unique email index
      // createIndex() uses the implicit transaction automatically via the store reference.
      if (oldVersion < 2) {
        const store = tx.objectStore('users');
        store.createIndex('email', 'email', { unique: true });
      }

      // Patch v3: Add login tracking index
      if (oldVersion < 3) {
        const store = tx.objectStore('users');
        store.createIndex('last_login', 'lastLogin', { unique: false });
      }
    };

    dbRequest.onsuccess = () => resolve(dbRequest.result);
    dbRequest.onerror = () => reject(dbRequest.error);
  });
}

Async Cursor-Based Data Backfill with Quota & Error Safeguards

Data backfills (transforming existing records) can be done inside onupgradeneeded using the implicit versionchange transaction, but for large datasets it is safer to run them after the database opens successfully to avoid blocking the upgrade:

export async function migrateUserProfiles(db: IDBDatabase): Promise<void> {
  // Check storage quota before heavy writes
  if (navigator.storage?.estimate) {
    const { usage = 0, quota = 1 } = await navigator.storage.estimate();
    if (usage / quota > 0.85) {
      console.warn('Storage quota nearing limit. Deferring heavy backfill.');
      throw new DOMException('Storage limit reached', 'QuotaExceededError');
    }
  }

  return new Promise<void>((resolve, reject) => {
    const tx = db.transaction('users', 'readwrite');
    const store = tx.objectStore('users');

    tx.oncomplete = () => resolve();
    tx.onerror = () => {
      console.error('Migration transaction failed:', tx.error);
      reject(tx.error);
    };

    const cursorRequest = store.openCursor();

    cursorRequest.onsuccess = (event) => {
      const cursor = (event.target as IDBRequest<IDBCursorWithValue | null>).result;
      if (!cursor) return; // No more records; tx will auto-commit

      const legacyData = cursor.value;
      if (!legacyData.metadata) {
        const updatedRecord = {
          ...legacyData,
          metadata: { migratedAt: Date.now(), version: TARGET_DB_VERSION },
        };
        cursor.update(updatedRecord);
      }
      cursor.continue();
    };

    cursorRequest.onerror = () => reject(cursorRequest.error);
  });
}

Troubleshooting & Common Pitfalls

Symptom Root Cause Resolution
InvalidStateError during createObjectStore Attempting schema modifications outside onupgradeneeded Restrict all structural changes to the versionchange transaction lifecycle.
Silent upgrade failure Incrementing indexedDB.open() version without corresponding onupgradeneeded logic Implement strict oldVersion conditional patches and attach onerror listeners.
TransactionInactiveError during backfill Async operations (e.g., await fetch()) yielding inside the transaction Run backfills after onsuccess, not inside onupgradeneeded. Batch all cursor requests synchronously.
First-time users missing initial schema Failing to handle oldVersion === 0 Ensure if (oldVersion < 1) covers initial object store and index creation.
Partially migrated records Unhandled cursor exceptions or missing transaction completion Wrap cursor loops in try/catch, await tx.oncomplete, and implement explicit rollback telemetry.

Debugging Tip: Use Chrome DevTools → Application → IndexedDB to inspect object stores before and after upgrades. Enable indexedDB.open() logging to trace oldVersion vs newVersion deltas during local development.


Frequently Asked Questions

How do I handle schema migrations when the user is completely offline? IndexedDB migrations execute entirely client-side and do not require network connectivity. The onupgradeneeded event triggers locally when the app requests a higher version number, ensuring offline-first state persistence remains intact regardless of network status.

What happens if a user skips multiple app versions and opens the app? The browser fires onupgradeneeded with the oldVersion matching the currently stored database version. Your migration logic must use conditional checks (e.g., if (oldVersion < 2)) to apply all intermediate patches sequentially, ensuring data integrity across version jumps.

Can I roll back a failed schema migration in the browser? IndexedDB does not support manual rollback of schema changes mid-transaction. If the upgrade transaction fails or is aborted, the database reverts to its previous state automatically. Implement explicit error handling and version tracking to allow manual recovery or fallback to a safe schema version.

Should I use a wrapper library for complex migrations? Wrapper libraries like idb or Dexie provide promise-based abstractions that simplify transaction management and cursor handling. idb in particular exposes tx.done as a promise that resolves on transaction completion, eliminating the need for manual oncomplete/onerror wiring. Evaluate your team’s familiarity with async patterns before introducing additional dependencies.