IndexedDB Schema Migration: Versioning and onupgradeneeded Done Right
AI generated
JS
() =>
JavaScript · IndexedDB · Schema Migration · Offline Databases
IndexedDB schema migration: versioning and onupgradeneeded done right
database upgrades without data loss and without cross-tab conflicts

Schema migration in IndexedDB differs fundamentally from server side database migrations: there is no migration tool, no SQL DDL commands and no central place to run scripts, only the onupgradeneeded event, a single version number, and the developer's responsibility to change object stores and indexes step by step, robustly and backward compatibly.

18 min read onupgradeneeded · versionchange · blocked · migration functions Chrome · Firefox · Safari

1. Why IndexedDB migrations work differently from server side ones

Anyone coming from the world of server side databases typically expects a schema migration to involve a migration tool with numbered script files, a history of executed migrations, and the ability to roll back individual steps deliberately. IndexedDB offers none of that. Instead, every IndexedDB schema migration is based on a single integer, the database version, and a single event, onupgradeneeded, which fires exactly when the version requested in code is higher than the version currently stored in the browser.

This reduction to a single event means the entire logic of an IndexedDB schema migration, from creating new object stores through adding additional indexes to transforming existing records, has to converge into a single function. There is no separate migrations folder, no automatic ordering of script files, and no built in history of which migration has already run, other than the current version number itself. Anyone who takes IndexedDB schema migrations seriously has to rebuild this structure themselves in the application code.

The second fundamental difference: an IndexedDB schema migration does not just affect the current tab, but potentially several simultaneously open tabs of the same origin using the same database. A database cannot be upgraded to a new version as long as an older connection is still open, which turns IndexedDB schema migrations into a coordination problem between tabs, not merely a pure data structure problem.

2. The onupgradeneeded event in detail

The entry point of every IndexedDB schema migration is the call indexedDB.open(name, version) with an explicit version number. If the supplied version is higher than the last stored one, the browser fires the onupgradeneeded event before the database is released for normal read and write access. Inside this event, and only there, object stores may be created with createObjectStore() or removed with deleteObjectStore(), as can indexes with createIndex() or deleteIndex().

The event object provides two decisive values for every IndexedDB schema migration: event.oldVersion, the previously stored version, and event.newVersion, the newly requested version. For a brand new database, oldVersion equals 0, which is excellent for distinguishing initial setup from a real migration of an existing database. The entire migration logic runs inside the same transaction that the browser implicitly opens for onupgradeneeded, the so called versionchange transaction type.


// Core structure of an IndexedDB schema migration
function openDatabase() {
  const CURRENT_VERSION = 4;
  const request = indexedDB.open("app_db", CURRENT_VERSION);

  request.onupgradeneeded = (event) => {
    const db = event.target.result;
    const oldVersion = event.oldVersion;

    console.log(`Migrating from version ${oldVersion} to ${event.newVersion}`);

    if (oldVersion < 1) {
      db.createObjectStore("orders", { keyPath: "id" });
    }
    if (oldVersion < 2) {
      const store = event.target.transaction.objectStore("orders");
      store.createIndex("byStatus", "status");
    }
    if (oldVersion < 3) {
      db.createObjectStore("customers", { keyPath: "id" });
    }
    if (oldVersion < 4) {
      const store = event.target.transaction.objectStore("orders");
      store.createIndex("byCustomerAndDate", ["customerId", "createdAt"]);
    }
  };

  return new Promise((resolve, reject) => {
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

3. Version numbers: integers instead of semver

Unlike most package management systems, IndexedDB expects a simple integer for every schema migration, not a semver notation like 2.1.0. The browser compares purely numerically: any version higher than the stored one triggers onupgradeneeded, regardless of how many version bumps lie in between. A migration from version 1 directly to version 5 does not skip intermediate steps automatically, it runs through all the conditions from oldVersion < 2 to oldVersion < 5 sequentially inside the same event.

A common mistake in managing version numbers for IndexedDB schema migrations is not maintaining the version number as a single, central constant that is consistently incremented with every structural change, but scattering it across multiple places in the code. If a version number is forgotten to be bumped despite a structural database change, existing users never see the new structure, because onupgradeneeded simply never fires. This mistake is particularly insidious because it stays invisible in a local development environment with a freshly deleted database and only surfaces with real users who have existing data.

4. Step by step migration functions per version bump

For more complex applications with many version bumps, a single onupgradeneeded function with nested if blocks quickly becomes hard to follow. A more robust structure for IndexedDB schema migrations encapsulates each version bump in its own, named function that performs exactly one structural change, and calls these functions sequentially in a loop from oldVersion to newVersion. This makes each migration individually testable and the overall logic traceable, similar to numbered migration files in server side ORMs.

This structure pays off especially when an IndexedDB schema migration needs to reshape existing records, not just affect object stores or indexes, for example when a field needs to be renamed or a new required field needs to be backfilled with a default value. Since onupgradeneeded grants access to the full transaction, cursor based data transformations can be executed directly inside the migration, without needing separate post processing after opening the database.


// Structured migration steps: one function per version bump
const migrations = {
  1: (db) => {
    db.createObjectStore("orders", { keyPath: "id" });
  },
  2: (db, tx) => {
    tx.objectStore("orders").createIndex("byStatus", "status");
  },
  3: (db) => {
    db.createObjectStore("customers", { keyPath: "id" });
  },
  4: (db, tx) => {
    tx.objectStore("orders").createIndex("byCustomerAndDate", ["customerId", "createdAt"]);
  },
};

function runMigrations(db, tx, oldVersion, newVersion) {
  for (let version = oldVersion + 1; version <= newVersion; version++) {
    const step = migrations[version];
    if (step) {
      console.log(`Applying migration step ${version}`);
      step(db, tx);
    }
  }
}

5. Data transformation during migration

Some IndexedDB schema migrations require more than just structural changes to object stores and indexes, they need to adjust the content of existing records. A typical example: a field fullName should be split into firstName and lastName. Since onupgradeneeded grants access to the versionchange transaction, a cursor can be opened that walks through every existing record, transforms it, and writes it back with cursor.update(), all within the same atomic migration transaction.

Important for this kind of IndexedDB schema migration: the entire transformation has to remain synchronous within the running transaction, meaning no asynchronous fetch calls or timers may occur between cursor steps, since the transaction would otherwise automatically commit or abort before the migration finishes. For data transformations that require external data, for instance reconciling with a server, it is more robust to migrate the raw data purely structurally first and perform the content enrichment as a separate step after opening the database.


// Data transformation cursor inside a schema migration step
function splitFullNameIntoFirstAndLast(tx) {
  const store = tx.objectStore("customers");
  const request = store.openCursor();

  request.onsuccess = (event) => {
    const cursor = event.target.result;
    if (!cursor) return;

    const record = cursor.value;
    if (record.fullName && !record.firstName) {
      const [firstName, ...rest] = record.fullName.split(" ");
      record.firstName = firstName;
      record.lastName = rest.join(" ");
      delete record.fullName;
      cursor.update(record); // stays inside the same versionchange transaction
    }
    cursor.continue();
  };
}

6. The blocked event and cross-tab conflicts

An IndexedDB schema migration can only start if no other connection to the same database with an older version is still open. If a connection with the old version is still active in another tab, request.onblocked fires instead of onupgradeneeded, and the migration hangs until that other connection is closed. For users this often manifests as a seemingly frozen application waiting for a reload or for other tabs to be closed, with no error message appearing.

A robust pattern for IndexedDB schema migrations handles onblocked actively instead of ignoring it: the application can inform the user that other open tabs need to be closed, or actively try to close its own database connection in other tabs as soon as a versionchange event is received there. Without this handling, a pending IndexedDB schema migration looks like a bug to end users, even though it is expected but uncommunicated behavior.


// Handling the blocked event during a schema migration
const request = indexedDB.open("app_db", 5);

request.onblocked = (event) => {
  console.warn("Migration blocked: another tab still has an older connection open");
  showBanner("Please close other tabs of this application to continue");
};

request.onupgradeneeded = (event) => {
  // migration logic runs here once unblocked
};

7. Reacting to versionchange in other tabs

So that an IndexedDB schema migration in one tab is not permanently blocked by another tab, every open database connection should listen for the versionchange event, which fires on the existing connection exactly when another context tries to upgrade the database to a higher version. The recommended handling of this event is to close one's own connection in a controlled way, so the waiting migration in the other tab can proceed.

If this event is ignored, the old connection stays open, the waiting IndexedDB schema migration remains stuck in the blocked state, and in the worst case the user only notices something is wrong after manually restarting a tab. For applications with a typical multi tab usage pattern, such as admin interfaces or dashboards, clean versionchange handling is therefore not an optional detail but a prerequisite for reliable updates.


// Gracefully close the connection when another tab wants to migrate
function openDatabaseWithGracefulUpgrade() {
  const request = indexedDB.open("app_db", 5);

  request.onsuccess = () => {
    const db = request.result;
    db.onversionchange = () => {
      console.log("Another tab requested a schema upgrade, closing this connection");
      db.close();
      showBanner("This page has been updated, please reload");
    };
  };

  return request;
}

8. Error handling and rollback strategies

If an IndexedDB schema migration fails inside onupgradeneeded, for example because a transformation throws an exception, the entire versionchange transaction is automatically rolled back, and the database stays at the old version. This is an important safety mechanism: a failed IndexedDB schema migration leaves no half migrated database behind, it atomically returns to the previous, consistent state. The subsequent request.onerror handler receives the exception and can react appropriately, for example with a user notification or a retry.

For critical IndexedDB schema migrations, an additional defensive check after the migration is recommended: a simple count of the records in an affected object store, compared before and after the transformation, can reveal whether the migration logic unintentionally lost data, for example through a faulty filter in the cursor loop. Since there is no built in backup system for IndexedDB, such verification is the only safeguard against silent data loss during migration.

9. Migration strategies compared

The following table compares common approaches for IndexedDB schema migrations and their respective strengths and weaknesses.

Approach Structure When suitable Risk
Nested if blocks Everything in one onupgradeneeded function Few version bumps, small application Hard to follow with many versions
Numbered migration functions One function per version bump, executed sequentially Medium to large applications Somewhat more boilerplate code
Structural migration only Adjust object stores/indexes, leave data unchanged Pure structural changes without data transformation Old field names remain in place
Cursor based data transformation Reshape records inside the migration Field renames, new required fields Must stay synchronous within the transaction
Downstream enrichment Migrate structurally only, add data later Transformation needs external data Temporarily inconsistent data state

For most applications, a combination makes sense: numbered migration functions as the base structure, cursor based transformations for changes that can be done synchronously, and downstream enrichment only where external data is unavoidable. A well structured IndexedDB schema migration stays testable, traceable and robust as application complexity grows.

Mironsoft

JavaScript architecture, offline databases and schema design

IndexedDB migrations without data loss and without cross-tab deadlocks?

We structure IndexedDB schema migrations with numbered migration functions, safe data transformation, and robust blocked/versionchange handling for multi tab applications.

Migration framework

Numbered, testable migration functions instead of nested if blocks

Cross-tab coordination

Blocked and versionchange handling for uninterrupted updates

Migration audit

Reviewing existing migration logic for data loss risks

10. Summary

An IndexedDB schema migration differs fundamentally from server side database migrations: no migration tool, no automatic script ordering, only a single integer version and the onupgradeneeded event as the sole place to run it. Numbered, self contained migration functions per version bump make this logic traceable and testable, while cursor based data transformations within the same transaction also allow content changes to existing records.

The biggest source of errors in IndexedDB schema migrations is not the structural logic itself but cross-tab behavior: without clean handling of blocked and versionchange, migrations get stuck while another tab still holds an old connection open. Anyone who consistently handles these two events and understands the versionchange transaction as an atomic unit builds migrations that run reliably and without data loss even in multi tab scenarios.

IndexedDB schema migration — the essentials at a glance

onupgradeneeded

The only place for createObjectStore(), createIndex() and structural changes, triggered by a higher version number.

Version number

A simple integer, maintained centrally, every structural change requires an increment.

Data transformation

Cursor based within the versionchange transaction, must stay synchronous.

Cross-tab behavior

Actively handle blocked and versionchange events to avoid deadlocks.

11. FAQ: IndexedDB Schema Migration

1What triggers a migration?
A higher version number in indexedDB.open() triggers onupgradeneeded.
2What do oldVersion/newVersion provide?
Previously stored and newly requested version number, 0 for a new database.
3Skip version bumps?
Possible, but all intermediate steps must be executed within the same onupgradeneeded.
4Structure many steps clearly?
One function per version bump, called sequentially in a loop.
5Modify records during migration?
Yes, via a cursor inside the transaction, must stay synchronous.
6What does the blocked event mean?
Fires when another connection with an old version blocks the migration.
7Avoid getting stuck in other tabs?
Listen for versionchange and close your own connection with close().
8What happens on failure?
Automatic rollback of the entire transaction, no half migrated state.
9Is there a backup system?
No, defensive verification like record counting is the only safeguard.
10Where can createObjectStore/createIndex be called?
Only inside onupgradeneeded, otherwise InvalidStateError.