This page addresses the operation that most often destroys the snapshots described in LocalStorage & IndexedDB Sync Strategies: changing the shape of the database they live in.

The exact problem #

Version 4 of an editor adds a wordCount field and renames body to content. The team bumps the IndexedDB version, and in onupgradeneeded they do the obvious thing:

// The three lines that delete every user's unsaved work
if (db.objectStoreNames.contains('drafts')) db.deleteObjectStore('drafts');
db.createObjectStore('drafts', { keyPath: 'id' });

Every draft on every device is gone at the moment users open the new build β€” including the drafts of the users who most needed recovery, because they are the ones who had unsaved work when the tab crashed.

The fix is not complicated, but it has three sharp edges: the migration must run inside the versionchange transaction, it must handle a second tab holding the old version open, and it must fail without destroying data.

Zero-to-working migration runner #

// db.ts
const DB_NAME = 'app_recovery';
export const DB_VERSION = 4;

type Migration = (db: IDBDatabase, tx: IDBTransaction) => void;

/** Index i migrates from version i to version i+1. */
const MIGRATIONS: Migration[] = [
  // 0 β†’ 1: initial schema
  (db) => {
    const drafts = db.createObjectStore('drafts', { keyPath: 'id' });
    drafts.createIndex('byUpdatedAt', 'updatedAt');
  },
  // 1 β†’ 2: snapshots for wizard flows
  (db) => {
    db.createObjectStore('flows', { keyPath: 'flowKey' });
  },
  // 2 β†’ 3: index drafts by document so multi-doc recovery can query
  (_db, tx) => {
    tx.objectStore('drafts').createIndex('byDocument', 'documentId');
  },
  // 3 β†’ 4: rename body β†’ content and add wordCount, IN PLACE
  (_db, tx) => {
    const store = tx.objectStore('drafts');
    const cursorReq = store.openCursor();
    cursorReq.onsuccess = () => {
      const cursor = cursorReq.result;
      if (!cursor) return;
      const record = cursor.value as Record<string, unknown>;
      if ('body' in record) {
        record.content = record.body;
        delete record.body;
      }
      record.wordCount = countWords(String(record.content ?? ''));
      cursor.update(record);     // an IDB request: keeps the transaction alive
      cursor.continue();
    };
  },
];

export function openDb(): Promise<IDBDatabase> {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(DB_NAME, DB_VERSION);

    req.onupgradeneeded = (event) => {
      const db = req.result;
      const tx = req.transaction!;              // the versionchange transaction
      const from = event.oldVersion;

      try {
        // Run EVERY step between the user's version and ours, in order.
        for (let v = from; v < DB_VERSION; v += 1) MIGRATIONS[v](db, tx);
      } catch (err) {
        // Abort keeps the database at its old version with data intact.
        tx.abort();
        reportCrash({ scope: 'idb-migration', message: `migration ${from}β†’${DB_VERSION} failed`, cause: String(err) });
      }
    };

    req.onsuccess = () => {
      const db = req.result;
      // Another tab is upgrading: let go so it can proceed.
      db.onversionchange = () => { db.close(); notifyStaleTab(); };
      resolve(db);
    };

    req.onblocked = () => {
      // An older tab still holds the DB. Ask the user to close it.
      showBlockedNotice();
    };

    req.onerror = () => reject(req.error);
  });
}
Stepwise migration versus delete and recreate The upper path shows four version boxes connected by arrows labelled with each migration step, with a note that a user on version two runs only the last two steps and keeps their records. The lower path shows a single arrow from version two to version four labelled delete and recreate, ending in an empty store marked all drafts lost. stepwise migration v1 v2 v3 v4 records kept a user on v2 runs 2β†’3 then 3β†’4; every record is transformed forward delete and recreate v2 deleteObjectStore + create v4 β€” empty all drafts lost

Step-by-step #

  1. Index migrations by source version. MIGRATIONS[v] upgrades from v to v+1. A user on version 1 runs three functions; a fresh install runs all four. There is no separate β€œcreate” path to drift out of sync.
  2. Use req.transaction inside onupgradeneeded. That versionchange transaction is the only one that can create stores and read existing records. Opening your own transaction there throws.
  3. Transform with a cursor. cursor.update() is an IndexedDB request, so it keeps the transaction alive. This is the mechanism that renames a field without losing the record.
  4. Never await non-IDB work inside the handler. A fetch or a timer lets the transaction auto-commit; the next write throws TransactionInactiveError and your migration half-applies.
  5. Abort on failure. tx.abort() rolls the whole upgrade back β€” including the version bump β€” so the user keeps their data on the old schema and can be served a read-only recovery path.
  6. Close on versionchange. Every tab must release its connection when another tab upgrades, or the upgrade blocks indefinitely and the new tab hangs.
Event Fires when Correct response
upgradeneeded The requested version is higher than stored Run the migration steps in order
blocked Another connection still holds an older version Tell the user to close other tabs; retry after
versionchange (on an open db) Another tab is upgrading db.close() and switch that tab to a read-only or reload prompt
abort on the upgrade transaction A migration threw Keep the old version; report; degrade gracefully
error on open Storage unavailable, private mode Fall back to in-memory state and warn about durability

Recovering when a migration fails #

An aborted upgrade leaves the database usable at its old version β€” which is exactly what you want, provided the application can still read it.

// safeOpen.ts
export async function openWithFallback(): Promise<{ db: IDBDatabase; degraded: boolean }> {
  try {
    return { db: await openDb(), degraded: false };
  } catch {
    // The upgrade failed. Open at whatever version exists and go read-only,
    // so the user can still RECOVER a draft even if they cannot save new ones.
    const db = await new Promise<IDBDatabase>((resolve, reject) => {
      const req = indexedDB.open(DB_NAME);        // no version = current version
      req.onsuccess = () => resolve(req.result);
      req.onerror = () => reject(req.error);
    });
    return { db, degraded: true };
  }
}

Degraded mode should say so. A banner reading β€œRecovery data is available but cannot be updated in this version β€” your existing drafts are safe” is far better than silently writing to a store that will be discarded, and it pairs with the offline messaging patterns in Connectivity Detection & Offline UX Patterns.

Resolving a blocked upgrade across two tabs Two horizontal lanes labelled tab A on version three and tab B opening version four. Tab B's open fires blocked. Tab A receives versionchange and closes its connection. Tab B's upgrade then proceeds and completes, and tab A shows a reload prompt. tab A (v3) tab B (opens v4) holding a connection open(v4) β†’ blocked versionchange β†’ tab A closes upgrade runs and completes "reload to continue" prompt without the versionchange handler, tab B waits forever and its user sees a permanent spinner What keeps the upgrade transaction alive A horizontal band represents the versionchange transaction. Cursor and put requests inside it are marked as keeping it open. An await on a fetch is marked as a gap, after which the transaction has auto-committed and a following put throws TransactionInactiveError. transaction alive cursor.update() store.put() await fetch() no IDB request β†’ auto-commit store.put() β†’ throws TransactionInactiveError only IndexedDB requests extend a versionchange transaction do every fetch, prompt or timer before opening the database

Verification #

  1. Seed an old database. In a test, open at version 1, write representative records, close, then open at version 4 and assert every record survived with the new field names.
  2. Test every supported hop. 1β†’4, 2β†’4 and 3β†’4 all need coverage; a bug in MIGRATIONS[2] is invisible to a 3β†’4 test.
  3. Force a throwing migration. Confirm tx.abort() leaves the stored version unchanged and the records intact, and that the app enters degraded mode rather than an error screen.
  4. Reproduce the block. Open two tabs, upgrade in one; the other must close its connection and prompt, and the upgrade must complete within a second.
  5. Check quota interaction. A migration that duplicates records temporarily can exceed the origin’s quota β€” measure with navigator.storage.estimate() before and after, and see Handling QuotaExceededError When Persisting Session State.

Frequently asked questions #

Why did my users lose their drafts after a deploy?

Because the upgrade deleted and recreated the store rather than transforming its contents, or called deleteDatabase. Transform in place inside onupgradeneeded and never delete a store holding user data.

What happens if the user has two tabs open during an upgrade?

The new tab’s open fires blocked and waits. Listen for versionchange in every tab, close the connection there, and prompt if the block persists.

Can I run async work inside onupgradeneeded?

Only IndexedDB requests. Awaiting a fetch or timer lets the transaction auto-commit and later writes throw TransactionInactiveError.