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.
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.
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.
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.
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
onversionchangehandler. 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;
versionchangeandblockedbehave 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
- 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.
- Stop the worker mid-migration and restart it; confirm the migration resumes and no records are lost or duplicated.
- Run the upgrade tests from every fixture version in CI.
- 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.
Related
- Running data migrations on onInstalled — migrations for chrome.storage.
- Keeping IndexedDB fast in an extension — performance of the new schema.
- Persisting job progress across worker restarts — the batching pattern used here.
- Extension updates and data migration — the parent topic.