Migrating IndexedDB Schemas on Update

Upgrade IndexedDB schemas safely in an MV3 extension: onupgradeneeded versioning, migrating data between stores, blocked upgrades from open popups and panels, batching large migrations, and rolling back.

Published October 2, 2026 Updated October 2, 2026 8 min read
Table of Contents

Version 3 of the extension stores saved articles in IndexedDB with a new index for tags and a split of article bodies into a separate store. Users updating from version 2 have thousands of records in the old shape. The migration has to run exactly once, finish even if it takes longer than the service worker would like, not lose data if it is interrupted, and not deadlock against an options page that the user left open with the old version’s database connection. IndexedDB’s built-in versioning handles part of this; the extension lifecycle makes the rest your job. This guide covers both. It belongs to extension updates and data migration.

How IndexedDB versioning works

Every IndexedDB database has an integer version. Opening it with a higher version than the stored one fires onupgradeneeded inside a special version change transaction, the only place where object stores and indexes can be created or deleted. That transaction is atomic: if anything throws, the whole upgrade rolls back and the database stays at the old version. Two constraints matter in extensions. First, the upgrade cannot start while any other connection to the same database is open — the old version’s popup, options page or offscreen document — and the open request stays blocked until they close. Second, the version change transaction lives only as long as you keep issuing requests in it; awaiting a fetch or a chrome.storage call inside onupgradeneeded lets it auto-commit, ending the upgrade half-done.

A schema upgrade blocked by an open pageThe updated worker opens the database at version 3; an old options page still holds a version 2 connection; the old connection receives versionchange and closes; the upgrade transaction runs and commits.Worker (v3 code)IndexedDBOptions page (v2 conn)open('readable', 3)versionchange eventdb.close()onupgradeneeded (2 → 3)create stores, migrateonsuccess (v3)
Every old connection must close before the upgrade can run.

Step-by-step: safe schema upgrades

1. Write incremental upgrade steps

 1// db.js
 2const DB_NAME = "readable";
 3const DB_VERSION = 3;
 4
 5const UPGRADES = {
 6  1: (db) => { db.createObjectStore("articles", { keyPath: "id" }); },
 7  2: (db, tx) => { tx.objectStore("articles").createIndex("bySaved", "savedAt"); },
 8  3: (db, tx) => {
 9    db.createObjectStore("bodies", { keyPath: "id" });
10    tx.objectStore("articles").createIndex("byTag", "tags", { multiEntry: true });
11  },
12};
13
14export function openDb() {
15  return new Promise((resolve, reject) => {
16    const req = indexedDB.open(DB_NAME, DB_VERSION);
17    req.onupgradeneeded = (e) => {
18      const db = req.result, tx = req.transaction;
19      for (let v = e.oldVersion + 1; v <= DB_VERSION; v++) UPGRADES[v]?.(db, tx);
20    };
21    req.onblocked = () => console.warn("[db] upgrade blocked by an open connection");
22    req.onsuccess = () => { attachVersionChange(req.result); resolve(req.result); };
23    req.onerror = () => reject(req.error);
24  });
25}

Execution context: a module shared by the worker and extension pages, all on the extension origin. Applying every step from oldVersion + 1 to the target means a user jumping from version 1 to 3 runs both steps in order, and a fresh install runs all of them. Each step must be synchronous IndexedDB work only — no await of anything outside the transaction.

2. Close old connections when asked

1function attachVersionChange(db) {
2  db.onversionchange = () => {
3    db.close();
4    // In a page: tell the user to reopen; in the worker: the next openDb() call will reconnect
5    if (typeof document !== "undefined") document.body.dataset.stale = "true";
6  };
7}

Execution context: every context that opens the database. When a newer version requests an upgrade, every existing connection receives versionchange; closing promptly lets the upgrade proceed. If the old code never handles it — as in many first versions — the upgrade stays blocked until the user closes the stale page. Add this handler from your very first release so future upgrades are never blocked by your own pages.

Schema change versus data migrationStructural changes — new stores and indexes — run synchronously inside onupgradeneeded; moving and transforming large amounts of data runs afterwards in resumable batches with a progress marker.onupgradeneededstores + indexesAtomicall or nothingCommits fastno awaitsthen, outside the upgradeBatch migrate500 records / txnProgress markerstorage.localResume on restartonStartup
Keep the version change transaction small; do bulk data work after it commits.

3. Keep the version change transaction small

1// Inside upgrade 3: only structural changes and a cheap marker
23: (db, tx) => {
3  db.createObjectStore("bodies", { keyPath: "id" });
4  tx.objectStore("articles").createIndex("byTag", "tags", { multiEntry: true });
5  tx.objectStore("articles").put({ id: "__migration__", splitBodies: "pending" });
6},

Execution context: the upgrade handler. Moving thousands of article bodies inside the version change transaction is possible — it is all IndexedDB work — but it holds an exclusive lock on the whole database, blocks every other open request, and if the worker is terminated mid-way, the entire upgrade rolls back and starts again next time. Create the structure atomically, record that a data migration is pending, and do the bulk work afterwards in batches.

4. Migrate data in resumable batches

 1export async function runPendingMigrations() {
 2  const db = await openDb();
 3  for (;;) {
 4    const moved = await new Promise((resolve, reject) => {
 5      const tx = db.transaction(["articles", "bodies"], "readwrite");
 6      const articles = tx.objectStore("articles"), bodies = tx.objectStore("bodies");
 7      let count = 0;
 8      articles.openCursor().onsuccess = (e) => {
 9        const cur = e.target.result;
10        if (!cur || count >= 500) return;
11        const a = cur.value;
12        if (a.body !== undefined && a.id !== "__migration__") {
13          bodies.put({ id: a.id, body: a.body });
14          delete a.body;
15          cur.update(a);
16          count++;
17        }
18        cur.continue();
19      };
20      tx.oncomplete = () => resolve(count);
21      tx.onerror = () => reject(tx.error);
22    });
23    if (moved === 0) break;
24  }
25}

Execution context: the service worker, called from onInstalled and onStartup. Each transaction moves up to 500 records and commits; a termination loses at most one batch, which is redone next time because unmigrated records still have a body field. The migration is idempotent by construction — it only touches records that still need it. Reading code must handle both shapes until it completes. Mark the marker record done at the end.

Where each migration task belongsCreating stores, adding indexes, deleting stores, transforming every record and backfilling from the network, with where each should run and whether it is atomic.TaskRun inAtomicCreate store / indexonupgradeneededYesDelete storeonupgradeneeded (after data moved)YesTransform all recordsBatched transactionsPer batchBackfill from networkSeparate jobNo
Structure in the upgrade; data in batches; network never inside a transaction.

5. Delete old structures in a later version

14: (db) => { /* only after v3's data migration has shipped and run widely */ db.deleteObjectStore("legacyCache"); },

Execution context: a future upgrade step. Removing a store destroys its data irreversibly. Ship the data migration in one version, and remove the old structure in a later one, after telemetry shows the migration completed for nearly everyone. That gap is also your rollback window.

6. Plan for rollbacks

IndexedDB versions only move forward: if you roll back to the previous extension build, its open(name, 2) fails with a VersionError against a version-3 database. Rollback releases must either open with the higher version number (with an upgrade handler that tolerates the newer structure) or ship as a new version that understands the current schema. See rolling back a bad extension release.

7. Test upgrades from every supported version

1// test: seed a v1 database with fixture data, then open with current code
2await seedDb("readable", 1, fixturesV1);
3const db = await openDb();
4await runPendingMigrations();
5expect(await countBodies(db)).toBe(fixturesV1.length);

Execution context: an automated test in a browser environment or with fake-indexeddb in Node. Upgrades from the oldest version you still support are the ones that break, because nobody runs them by hand. Keep fixture databases for each historical schema and run the upgrade path from each in CI.

Common mistakes

  • Awaiting non-IndexedDB work in onupgradeneeded. The transaction auto-commits and the upgrade ends half-done.
  • No onversionchange handler. Old pages block upgrades indefinitely.
  • Bulk data moves inside the upgrade. Long exclusive locks and full rollback on interruption.
  • Deleting old stores in the same release. No rollback path.
  • Untested old-version upgrades. They fail only for long-time users.

Cross-browser variation

  • Chrome / Edge: IndexedDB on the extension origin, shared by the worker and pages; versionchange and blocked behave per spec.
  • Firefox: same semantics; extension IndexedDB is stored per add-on id and removed on uninstall.
  • Safari: IndexedDB works but has historically had quirks with large transactions; keep batches small.

Verification

  1. Install version 2, save data, keep the options page open, then update to version 3: the options page closes its connection and the upgrade completes.
  2. Stop the worker mid-migration and restart it; confirm the migration resumes and no records are lost or duplicated.
  3. Run the upgrade tests from every fixture version in CI.
  4. Open DevTools → Application → IndexedDB and confirm the new stores and indexes exist.

FAQ

Should I use a wrapper library?

idb makes the API promise-based and readable, and its upgrade callback follows the same rules. Wrappers do not change the constraints.

Can content scripts access my extension’s IndexedDB?

No — they use the page’s origin. Message the worker.

Is chrome.storage simpler for small data?

Yes. Use IndexedDB when you need indexes, large data or binary blobs.

Other MV3 Architecture & Extension Lifecycle Resources