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 schema migrations to prevent silent data corruption during updates. This guide extends the IndexedDB Architecture & Advanced Patterns parent guide with a focused playbook for evolving an IndexedDB schema in production. 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.

IndexedDB version upgrade decision flow A flow showing how indexedDB.open compares stored and requested versions, fires onupgradeneeded inside a versionchange transaction, applies incremental patches, then resolves or rolls back. open(name, v) request version v v > stored? compare versions onupgradeneeded versionchange tx apply v1..vN patches onsuccess resolve db abort / throw roll back to old schema yes

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 the parent IndexedDB Architecture & Advanced Patterns guide, 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 version number itself is an unsigned integer (technically a 64-bit value, though staying within safe JavaScript integer range is the practical rule). It is owned entirely by your application code: the browser never increments it for you. A clean convention is to keep the target version as a single exported constant alongside an ordered list of migration steps, so that every deployment that ships a schema change also bumps exactly one number. This pairing — one constant, one ordered migration list — is what makes a version jump from v1 to v4 deterministic rather than a guess.

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.

Two further events shape the lifecycle and are easy to forget. The blocked event fires on the open request when another tab still holds an open connection at the old version, preventing the upgrade from starting; that tab must close its connection (or respond to its own versionchange event) before the upgrade proceeds. The versionchange event fires on every existing open connection when a newer version is requested elsewhere — the correct response there is almost always to call db.close() so the upgrading tab is not blocked indefinitely. Coordinating which tab performs the upgrade is exactly the kind of cross-tab problem the Web Locks API for Cross-Tab Coordination was designed to solve: acquire a named lock before opening at a higher version so only one tab drives the migration while the others wait and reload.

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.

The reason if (oldVersion < N) blocks (rather than if (oldVersion === N - 1)) is the cornerstone of correctness here. A user upgrading from version 1 directly to version 4 must fall through every block from 2 to 4 in order, applying each structural change exactly once. Using equality checks would skip the intermediate steps and leave the database in a shape no migration ever produced — a store that exists but lacks an index added two versions later, for instance. The patches form a strictly ordered, append-only ledger: once a version ships to users, its block is frozen forever, and new schema work is always a new block at the end.

oldVersion on open Blocks that execute (target v4) Resulting schema
0 (brand new) <1, <2, <3, <4 Full current schema, built from scratch
1 <2, <3, <4 Patched forward three versions
3 <4 only Patched forward one version
4 none Already current; onupgradeneeded does not fire

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 walkthrough. 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.

There are two valid places to run a backfill, and the choice depends on dataset size. Small transformations (a few hundred records, no async work) can run inside onupgradeneeded using the implicit versionchange transaction, which has the advantage of being atomic with the schema change: if the backfill fails, the schema rolls back too. Large transformations should run after the database opens successfully, in their own readwrite transaction, so they do not hold the global versionchange lock for seconds while every other tab is frozen. The trade-off is that a post-open backfill is no longer atomic with the schema bump — your code must tolerate a state where the new store exists but some records have not yet been migrated. Writing a schemaVersion field into each record, or a separate “migration progress” marker, lets the app detect and resume an interrupted backfill rather than corrupting data.


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);

    // Another tab holds an older connection open and is blocking the upgrade.
    dbRequest.onblocked = () => {
      console.warn('Upgrade blocked: another tab must close its connection.');
    };
  });
}

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);
  });
}

Coordinating the upgrade across tabs

When several tabs of the same origin are open, only one should drive the upgrade. Acquiring a named lock first prevents two tabs from racing into onupgradeneeded and stepping on each other’s versionchange events:

export async function openWithLock(): Promise<IDBDatabase> {
  // Serialize the upgrade so a single tab performs it while others wait.
  if (navigator.locks?.request) {
    return navigator.locks.request('idb-upgrade:app_state', () => initDatabase());
  }
  return initDatabase();
}

This is the cross-tab discipline detailed in the Web Locks API for Cross-Tab Coordination guide: the lock guarantees one upgrade at a time, and the other tabs respond to their own versionchange events by closing and reloading once the migration finishes.


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.
Upgrade never fires (blocked) Another tab holds an open connection at the old version Listen for versionchange in every tab and call db.close(); coordinate with a Web Lock.
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. When an upgrade leaves the database in a broken intermediate state, the Recovering from a Failed IndexedDB Version Upgrade guide walks through detecting the corruption and rebuilding cleanly.

Browser Compatibility Notes

IndexedDB and the onupgradeneeded lifecycle are stable across Chrome, Firefox, Safari, and Edge, but a few engine-specific behaviors affect migrations.

Browser Migration behavior Known caveat
Chrome / Edge Full support; transaction.commit() available to flush early None significant for migrations
Firefox Full support; strict about reads inside versionchange tx Throws promptly on misuse, which surfaces bugs early
Safari (desktop) Full support since modern WebKit Historic versions silently dropped errors; test on real Safari
iOS Safari 16/17 Supported, but storage is subject to 7-day eviction A wiped database re-runs from oldVersion === 0 — the initial-schema block must be complete and idempotent

The iOS Safari point is the one that bites offline-first teams: when Intelligent Tracking Prevention clears the database, the next open starts from oldVersion === 0 and rebuilds the entire schema. If your if (oldVersion < 1) block is incomplete because you “patched” the base schema in a later block, the rebuilt database will be missing stores. Always keep the version-1 block as a complete description of the original schema.

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. Only data backfills that pull fresh records from a server need connectivity; pure structural changes do not.

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 if (oldVersion < N) blocks so that all intermediate patches apply sequentially. A user jumping from version 1 to version 4 falls through every block from 2 to 4 in order, reaching the same schema a fresh install would.

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 — the Recovering from a Failed IndexedDB Version Upgrade guide covers the detection and rebuild path.

Should I use a wrapper library for complex migrations?

Promise-based wrappers such as the idb library or Dexie simplify transaction management and cursor handling. The idb library in particular exposes a transaction-done promise that resolves on completion, eliminating manual oncomplete/onerror wiring. Evaluate your team’s familiarity with async patterns before introducing additional dependencies; the raw API shown here has no third-party footprint.

Related