A shipped web app needs a new IndexedDB index and a reshaped record format. How does an IndexedDB version upgrade actually run, and what do the blocked and versionchange events have to do with tabs the user already has open?
answer
- an integer number is the whole schema story
- only one transaction may create stores
- fall through, do not branch
- other tabs hold the door shut
- there is no going back down
basics
~20 sCalling indexedDB.open(name, higherVersion) fires upgradeneeded and gives you an exclusive versionchange transaction — the only place stores and indexes can be created and existing records rewritten. Older tabs still holding the database block that upgrade until they close.
solid answer
~50 sSchema in IndexedDB is versioned by an integer you pass to `indexedDB.open(name, version)`. If the stored version is lower, the browser fires `upgradeneeded` on the open request before `success`, with `event.oldVersion` and `event.newVersion`, and `request.transaction` is a `versionchange` transaction — exclusive, and the only context where `createObjectStore`, `createIndex`, `deleteIndex`, and `deleteObjectStore` are legal. Because it is a real transaction you can also read and rewrite records in it, so schema change and data migration commit or roll back together. Write the handler as a switch on `oldVersion` with deliberate fall-through, so a client three versions behind replays every step. The multi-tab part is the trap: an existing connection in another tab prevents the upgrade, the browser fires `versionchange` on that old connection and `blocked` on the new open request. Handle `versionchange` by calling `db.close()` and telling the user to reload. Opening with a version *lower* than the stored one fails with `VersionError` — there is no downgrade.
code
javascript · 28 linesfunction openDb() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('shop', 3);
request.onupgradeneeded = (event) => {
const db = event.target.result;
const tx = event.target.transaction;
switch (event.oldVersion) {
case 0:
db.createObjectStore('orders', { keyPath: 'id' });
// falls through
case 1:
tx.objectStore('orders').createIndex('byCustomer', 'customerId');
// falls through
case 2:
db.createObjectStore('outbox', { autoIncrement: true });
}
};
request.onblocked = () => console.warn('Close other tabs to upgrade');
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const db = request.result;
db.onversionchange = () => db.close();
resolve(db);
};
});
}go deeper
Know that IndexedDB schema changes only happen inside upgradeneeded, triggered by opening the database with a higher version number, and that this is where stores and indexes are created.
Explain the versionchange transaction's exclusivity, why oldVersion is 0 for a fresh database, and why migration steps are written as cumulative fall-through cases rather than exact-version branches.
Demonstrate that you have shipped one: blocked and versionchange handled in both directions, migration data rewrites kept inside the upgrade so they roll back together, and VersionError anticipated when a release is rolled back.
Own the evolution strategy across a fleet of clients that update whenever they feel like it — when to migrate eagerly versus tolerate old shapes on read, how long compatibility branches live, and what a rollback means for data you cannot downgrade.
## Version is the schema An IndexedDB database carries a monotonically increasing integer version. That number is the entire schema-management mechanism: there is no DDL you can run at will, and no way to add a store or an index outside a version change. `indexedDB.open(name, version)` compares what you ask for against what is stored and takes one of three paths — equal, open normally; higher, run an upgrade first; lower, fail the request with a `VersionError`. ## The upgradeneeded handler ```js const request = indexedDB.open('shop', 3); request.onupgradeneeded = (event) => { const db = event.target.result; const tx = event.target.transaction; // the versionchange transaction switch (event.oldVersion) { case 0: db.createObjectStore('orders', { keyPath: 'id' }); // falls through case 1: tx.objectStore('orders').createIndex('byCustomer', 'customerId'); // falls through case 2: { const store = tx.objectStore('orders'); store.openCursor().onsuccess = (e) => { const cursor = e.target.result; if (!cursor) return; const value = cursor.value; value.total = { amount: value.total, currency: 'USD' }; cursor.update(value); cursor.continue(); }; } } }; ``` Four things are worth naming. `event.oldVersion` is `0` for a brand-new database, which is how first-install and upgrade share one code path. The switch **falls through** on purpose: a user who last opened the app at version 1 must replay steps 2 and 3, and the only maintainable way to guarantee that is one migration step per case, never rewritten retroactively. The `versionchange` transaction is exclusive over the whole database, so nothing else touches it while you work. And because it is a real transaction, the reshaping cursor above and the `createIndex` above it commit atomically — if anything throws, the transaction aborts, the version is not bumped, and the app reopens on the old schema. That last property is why data migration belongs *inside* the upgrade rather than in a follow-up readwrite transaction. A plain `readwrite` transaction cannot do any of this: `createObjectStore` outside a `versionchange` transaction throws `InvalidStateError`. ## The multi-tab problem A web app is not a single process. The user may have three tabs open on the old build when a fourth loads the new one. IndexedDB will not upgrade a database while other connections are open to it, so the sequence is: 1. The new tab calls `open(name, 3)`. 2. Every *other* open connection receives a `versionchange` event on its `IDBDatabase` object. 3. If any of them is still open shortly after, the new tab's open request fires `blocked`, and `upgradeneeded` does not run until the old connections close. The correct handling is symmetric and both halves are required: ```js // In every tab, right after opening: db.onversionchange = () => { db.close(); showBanner('A new version is available — reload this tab.'); }; // In the upgrading tab: request.onblocked = () => { showBanner('Close other tabs of this app to finish updating.'); }; ``` Without the `versionchange` handler, old tabs hold the database open indefinitely and the new tab hangs on `blocked` forever — a hang with no error, which is exactly the bug report that reads "the app just spins after the update". Note also that a tab whose connection you closed will fail its next transaction with `InvalidStateError`, so closing on `versionchange` should be paired with putting the UI into a read-only or reload-me state rather than pretending nothing happened. ## Deleting and downgrading There is no downgrade. Shipping a build that asks for a lower version than the user already has produces `VersionError` on every open, so a rollback of your app is not a rollback of their database — you must handle the newer schema, or detect the error and fall back. `indexedDB.deleteDatabase(name)` exists and is the nuclear option: it also fires `blocked` if connections are open, and it throws away user data, so it belongs behind an explicit reset action or a corrupted-state recovery path, not in a migration. ## Cost and safety Everything in the upgrade transaction happens before the app can use the database, and a cursor rewriting a large store is real work the user waits through. For big migrations, prefer schema changes that do not require touching every record — writing new records in the new shape while tolerating the old shape on read, then migrating lazily — and reserve full rewrites for when the read-side branch would be worse. Guarding with `if (!db.objectStoreNames.contains('orders'))` is a useful belt-and-braces check but is not a substitute for versioned steps. ## What people get wrong Rewriting old migration steps instead of appending new ones; using `if (oldVersion === 2)` so a client on version 1 skips a step; forgetting `onversionchange` and shipping a silent hang; and expecting a version downgrade to work.
- Why write the upgrade as a switch with fall-through rather than if/else on the exact old version?Because clients arrive from any earlier version, including 0. Fall-through replays every step between where they are and where you are, so a user who last opened the app two releases ago ends up with the same schema as a fresh install. Exact-match branching quietly skips intermediate steps and produces databases that exist in no version you ever tested.
- What happens if the migration code inside upgradeneeded throws?The `versionchange` transaction aborts, every schema change and data rewrite in it rolls back, and the stored version is not bumped — so the next open runs the same upgrade again rather than leaving a half-migrated database. That atomicity is the reason to do record reshaping inside the upgrade transaction instead of in a readwrite transaction afterwards.
- A user opens a tab running an older build after the database has already been upgraded. What do they see?Their `open(name, lowerVersion)` request fails with a `VersionError`, because IndexedDB has no downgrade path. The old build must therefore treat open failure as a real state: detect the error and show a reload prompt rather than crashing or silently running with no storage. This is the case teams forget when they roll a release back.
- How do you avoid a long blocking rewrite when millions of records need a new shape?Do not rewrite them in the upgrade. Bump the version to add the new index or store, then write new records in the new shape and tolerate the old shape on read, migrating each record lazily as it is touched or in idle-time batches. A version field on the record itself makes the read-side branch explicit and removable once telemetry says the old shape is gone.
saying these in an interview costs you the question
- Editing old migration steps instead of appending new ones
- Branching on the exact oldVersion so intermediate steps are skipped
- Never handling versionchange, so upgrades hang on old tabs
- Expecting to open a database with a lower version number
- Calling createObjectStore from an ordinary readwrite transaction