Recovering from a Failed IndexedDB Version Upgrade
A user reports the app is stuck on a loading spinner. The cause is an IndexedDB open() that never resolves: a migration in onupgradeneeded threw and left the version half-applied, or an old tab is still holding a connection to the previous version, so the upgrade is blocked. Upgrade transactions are all-or-nothing, but a thrown error aborts them and a stale connection stalls them indefinitely. This guide builds a recovery path that handles both. It is part of Database Schema Migrations within IndexedDB Architecture & Advanced Patterns.
Root cause: aborts and blocks
Two distinct failures land in the same place.
A thrown migration. The onupgradeneeded handler runs inside a special version-change transaction. If your migration code throws — a bad createIndex, a DataError from re-keying, an unexpected undefined — that transaction aborts and the whole version bump rolls back. IndexedDB is correctly all-or-nothing here, so the database stays at the old version. The danger is partial work in your own state (a flag you flipped, data you transformed in memory) that now disagrees with the un-upgraded schema. The disciplined step ordering that avoids this is the subject of Step-by-Step IndexedDB Version Upgrade Migration.
A blocked open. When you call open(name, newVersion) while another connection (typically a second browser tab) still holds the database at the old version, the upgrade cannot proceed. The new request fires onblocked and waits. It will only continue once every old connection closes — which is why every connection must listen for onversionchange and close itself so the upgrade elsewhere can run.
Step-by-step recovery
The recovery wrapper does four things: listen for onblocked, register onversionchange on the opened connection, wrap each migration step so a throw aborts cleanly and is retried, and keep a guarded last-resort rebuild.
1. Handle onblocked and onversionchange
Every successful open must register a versionchange listener so this tab steps aside when another tab triggers an upgrade. The opener must register onblocked so it can tell the user (or retry) instead of hanging silently.
interface OpenResult {
db: IDBDatabase;
}
function openWithCoordination(
name: string,
version: number,
migrate: (db: IDBDatabase, tx: IDBTransaction, from: number) => void,
): Promise<OpenResult> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(name, version);
req.onupgradeneeded = (event) => {
const db = req.result;
const tx = req.transaction!; // the version-change transaction
try {
migrate(db, tx, event.oldVersion);
} catch (err) {
// Abort so the version bump rolls back atomically.
tx.abort();
reject(err);
}
};
req.onblocked = () => {
// Another connection is still open on the old version.
reject(new Error('Upgrade blocked: another tab holds an old connection'));
};
req.onsuccess = () => {
const db = req.result;
// If another tab later requests a newer version, close so it can upgrade.
db.onversionchange = () => {
db.close();
// Optionally notify the UI to reload.
window.dispatchEvent(new CustomEvent('idb-version-change'));
};
resolve({ db });
};
req.onerror = () => reject(req.error);
});
}
2. Wrap migration steps so a failure aborts cleanly and retries
Express the schema as an ordered list of steps keyed by the version they introduce. Run only the steps newer than the database’s current version. Any throw aborts the version-change transaction; the open promise rejects, and the caller retries a bounded number of times.
type MigrationStep = (db: IDBDatabase, tx: IDBTransaction) => void;
const MIGRATIONS: Record<number, MigrationStep> = {
1: (db) => {
db.createObjectStore('notes', { keyPath: 'id' });
},
2: (db) => {
const notes = db.transaction('notes').objectStore('notes');
notes.createIndex('byUpdated', 'updatedAt');
},
3: (db, tx) => {
// Re-key example that could throw if data is malformed.
const store = tx.objectStore('notes');
store.createIndex('byAuthor', 'authorId', { unique: false });
},
};
function runMigrations(db: IDBDatabase, tx: IDBTransaction, from: number): void {
const target = db.version;
for (let v = from + 1; v <= target; v++) {
const step = MIGRATIONS[v];
if (step) step(db, tx); // a throw here aborts the whole version bump
}
}
async function openDatabase(
name: string,
version: number,
maxRetries = 2,
): Promise<IDBDatabase> {
let lastError: unknown;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const { db } = await openWithCoordination(name, version, (db, tx, from) =>
runMigrations(db, tx, from),
);
return db;
} catch (err) {
lastError = err;
// Brief backoff lets a blocking tab close after the versionchange event.
await new Promise((r) => setTimeout(r, 150 * (attempt + 1)));
}
}
throw lastError;
}
3. Guarded last-resort: back up, delete, rebuild
If the migration is genuinely unrecoverable (corrupt data the new schema cannot accept), the final option is deleteDatabase followed by a rebuild from the server. This is destructive, so guard it behind a backup of any data that only exists on the client. Coordinate the delete across tabs — deleteDatabase itself fires onblocked if connections remain open, the same way an upgrade does.
async function backupStore(db: IDBDatabase, store: string): Promise<unknown[]> {
return new Promise((resolve, reject) => {
const req = db.transaction(store, 'readonly').objectStore(store).getAll();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
function deleteDatabaseSafe(name: string): Promise<void> {
return new Promise((resolve, reject) => {
const req = indexedDB.deleteDatabase(name);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
req.onblocked = () =>
reject(new Error('Delete blocked: close other tabs first'));
});
}
async function recoverByRebuild(
name: string,
version: number,
restoreFromServer: () => Promise<void>,
): Promise<IDBDatabase> {
// 1. Best-effort backup of client-only data before destroying anything.
let backup: unknown[] = [];
try {
const stale = await openDatabase(name, version).catch(() => null);
if (stale) {
backup = await backupStore(stale, 'notes').catch(() => []);
stale.close();
}
} catch {
/* if we cannot even open it, proceed to delete */
}
// 2. Destroy and recreate the database with a clean schema.
await deleteDatabaseSafe(name);
const fresh = await openDatabase(name, version);
// 3. Re-hydrate from server, then replay any client-only backup that survived.
await restoreFromServer();
if (backup.length > 0) {
const tx = fresh.transaction('notes', 'readwrite');
for (const row of backup) tx.objectStore('notes').put(row);
}
return fresh;
}
Verification: simulate a throwing upgrade
Force a migration to throw and assert the database stays atomic and recovers:
async function verifyRecovery(): Promise<void> {
const name = 'recovery-test';
// Inject a throwing step at version 2.
const original = MIGRATIONS[2];
MIGRATIONS[2] = () => {
throw new Error('simulated bad migration');
};
let aborted = false;
try {
await openDatabase(name, 2, 0);
} catch (err) {
aborted = (err as Error).message.includes('simulated');
}
console.assert(aborted, 'expected the thrown migration to reject the open');
// The version must NOT have advanced — the bump rolled back atomically.
const dbs = await indexedDB.databases();
const entry = dbs.find((d) => d.name === name);
console.assert(
entry === undefined || entry.version === 1,
'version should not advance past a failed migration',
);
// Restore the good step and confirm the open now succeeds.
MIGRATIONS[2] = original;
const db = await openDatabase(name, 2);
console.assert(db.version === 2, 'recovery should reach version 2');
db.close();
}
In DevTools → Application → IndexedDB you can watch the database version stay at the old number after the throw, then jump to the target after the good migration runs.
Edge cases and fallback
Multi-tab coordination. The whole onblocked/onversionchange dance is really a cross-tab problem: an upgrade in one tab needs every other tab to release its connection. For flows where you must guarantee only one tab performs the migration at all, take an exclusive lock through the Web Locks API for Cross-Tab Coordination before calling open, so two tabs never race the same upgrade.
Preserving user data before a destructive reset. Never call deleteDatabase on a hunch. Run the backup step first, and only delete when you have either persisted client-only data elsewhere or confirmed the server holds an authoritative copy. A reset that loses unsynced offline edits is a worse failure than the spinner you started with.
Browsers without indexedDB.databases(). Safari historically lacked the databases() enumeration used in the verification above. Where it is missing, track your expected version in a separate, durable marker (for example a small record in a known store) and compare against it rather than enumerating.
Frequently Asked Questions
Does a thrown onupgradeneeded leave the database half-migrated?
No — the schema is safe. The version-change transaction is atomic, so a throw aborts it and the database stays at the previous version. The risk is only in your own out-of-band state (flags, in-memory transforms) that may now disagree with the un-upgraded schema, which is why migrations should mutate only inside the transaction.
Why does my open() hang forever instead of erroring?
It is blocked, not errored. Another connection (usually a second tab) still holds the database at the old version, so the upgrade waits and fires onblocked. Register an onblocked handler to surface it, and make every connection close itself on onversionchange so the upgrade can proceed.
Is deleteDatabase a safe recovery step?
Only as a guarded last resort. It destroys all data for that database and itself blocks while other connections are open. Back up any client-only data first, confirm the server can re-supply the rest, and coordinate the delete across tabs before calling it.
Related
- Database Schema Migrations — the parent guide on versioning and evolving an IndexedDB schema.
- Step-by-Step IndexedDB Version Upgrade Migration — the ordered, idempotent migration pattern that prevents most failures.
- Web Locks API for Cross-Tab Coordination — guarantee a single tab runs the upgrade with an exclusive lock.
- IndexedDB Transaction Management — the transaction semantics behind atomic version-change rollbacks.
- IndexedDB Architecture & Advanced Patterns — the overall data-layer design these recovery patterns protect.