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);
});
}
Step-by-step #
- Index migrations by source version.
MIGRATIONS[v]upgrades fromvtov+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. - Use
req.transactioninsideonupgradeneeded. Thatversionchangetransaction is the only one that can create stores and read existing records. Opening your own transaction there throws. - 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. - Never
awaitnon-IDB work inside the handler. Afetchor a timer lets the transaction auto-commit; the next write throwsTransactionInactiveErrorand your migration half-applies. - 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. - 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.
Verification #
- 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.
- 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. - 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. - 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.
- 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.
Related #
- LocalStorage & IndexedDB Sync Strategies β the storage layer this schema belongs to
- Syncing React State to IndexedDB for Crash Resilience β the writes a migration must preserve
- Form State Recovery & Multi-Step Wizards β schema-versioned snapshots that refuse to restore across incompatible shapes