IndexedDB migrations carry unsent work
X8B6’s browser database could contain the only copy of an inventory action, so a schema upgrade had to behave like a deployment rather than a disposable cache reset.
X8B6's first IndexedDB upgrade added an index to the outbox. The development database migrated cleanly because it contained a few sample operations I could recreate.
The frightening case was a real offline device with unsent work.
Its browser database might hold the only durable copy of an inventory adjustment. If the upgrade failed, cleared the store, or became blocked behind another tab, “reset local data” was not a harmless troubleshooting step. It could erase work that had never reached the server.
I began treating an IndexedDB migration as a deployment over user-owned state.
The local database was not merely a cache
X8B6 stored several categories of information together:
- Confirmed server snapshots, recoverable from the server.
- Receipts, recoverable while server retention still covered them.
- Pending operations, possibly unique to this device.
- Draft input, unique until committed as an operation.
- Derived indexes and view preferences, safe to rebuild.
Calling the whole database a cache flattened those differences. A migration strategy suitable for a confirmed snapshot—delete and fetch again—was unacceptable for a pending operation.
I marked stores by recovery class in the migration plan. Destructive changes had to explain how each unique record survived or became exportable. Derived data could be rebuilt after the upgrade and did not deserve complicated transformation code.
The classification also guided failure messages. “Local data could not be upgraded” was too vague. The interface needed to say whether unsent operations remained safely in the old version, whether the new app could open read-only recovery, or whether an export was required.
Storage location did not determine value. Recoverability did.
Database version was a one-way gate
IndexedDB tied schema changes to an integer database version. Opening X8B6 with a higher version triggered upgradeneeded and a version-change transaction. Object stores and indexes could be created or altered inside that transaction.
The transaction was valuable because an aborted upgrade did not leave half the schema committed. It also meant the migration code had one constrained execution window and could not casually wait for network data or user input.
I wrote upgrades as ordered steps based on oldVersion:
request.onupgradeneeded = function (event) {
var db = request.result;
var tx = request.transaction;
if (event.oldVersion < 2) migrateToV2(db, tx);
if (event.oldVersion < 3) migrateToV3(db, tx);
if (event.oldVersion < 4) migrateToV4(db, tx);
};A device could skip releases and move from version one to four in one open. Each step therefore had to work from the schema it declared, not assume every user had opened the intermediate application.
The version number was a schema boundary, not a release number. Incrementing it for unrelated UI changes would create unnecessary upgrade contention.
Other tabs could stop the upgrade
The first migration appeared to hang. Another X8B6 tab had the older database connection open, so the browser could not begin the version change.
The opening request's blocked event became a real interface state:
X8B6 needs to update local storage.
Another open tab is using the previous version.
Finish or save work there, then close or reload it.Every successful database connection also listened for versionchange. When a newer page requested an upgrade, the old tab stopped starting new transactions, closed its connection, and explained that a newer local-data version was ready.
It did not reload automatically while a person was editing. A draft outside the committed outbox still needed protection. The tab first committed or preserved the draft, then offered reload.
This coordination was not optional polish. Without it, a forgotten background tab could block a deployment indefinitely. Forcing the new tab to clear storage would punish the only copy of pending work for a connection-lifecycle bug.
The browser database belonged to the origin, not to one tab. Migrations made that shared ownership visible.
Creating an index over an existing store was a schema operation the version-change transaction could perform directly. Transforming every stored operation into a new payload shape could be expensive and failure-prone inside the upgrade.
I separated eager schema changes from lazy record normalization where possible.
The version upgrade created the new store or index and recorded the schema version. Existing records kept their original payload version. When application code read a version-one operation, a validated migration function converted it to the current in-memory form and wrote the upgraded record in an ordinary transaction.
This reduced the amount of work required before the database could open. It also let record migration failures be handled per operation instead of aborting an all-or-nothing pass over thousands of entries.
Lazy migration was not appropriate for every change. If the new index required a field absent from old records, the upgrade might need to populate a safe derived value or create a parallel store. The plan depended on what the new application needed to query immediately.
The guiding question was which transformations had to be atomic with the schema and which could preserve the old representation until touched.
Old records retained their own version
Relying only on the database version assumed every record matched the latest schema. Lazy migration, partial imports, and earlier bugs made that unsafe.
Each durable operation envelope gained a schemaVersion:
interface StoredOperationEnvelope {
schemaVersion: number;
operationId: string;
payload: any;
localState: string;
}The parser selected a migration chain from that value. Missing version meant the known original format, not “probably current.” Unknown future versions were preserved and rejected for mutation rather than guessed.
The record version described serialization shape. The database version described available stores and indexes. They advanced for different reasons.
This distinction made exports safer too. A recovery bundle could carry old records with their original schema version. Import did not need to know which application release produced them.
Version fields felt like overhead in a small database. They became cheap insurance as soon as unsent data could outlive the JavaScript bundle that created it.
Migration functions preserved intent
One old operation stored an absolute quantity. The new protocol distinguished adjust from recount. A mechanical migration could not infer whether 10 meant “I counted ten” or “remove two from twelve.”
I refused to invent a verb. Old pending records became a legacy operation type with explicit review behavior. The interface could show the old value and known base, then ask the person to confirm the intended action after reconnect.
Legacy quantity change: set displayed quantity to 10
Created on this device before operation types were recorded
Review as recount, adjustment, or discardConfirmed old records could be folded into a snapshot without needing to recreate lost intent. Pending records had a higher bar because migration changed what the server would eventually do.
This was an uncomfortable admission. A prettier migration would have produced all-current objects and silently assigned semantics the old system never captured.
Preserving an unknown state was safer than manufacturing a clean operation.
I briefly considered fetching server metadata during migration to resolve legacy item identifiers. The version-change transaction was the wrong place for an unreliable external dependency. A network delay could stall or abort the upgrade, and offline users were the ones most likely to need the local database.
The migration used only data already inside the database and deterministic code bundled with the application. If a record required server context, it migrated into a needs-resolution form that the normal application reconciled after opening.
This kept schema upgrade atomic and bounded. It also preserved offline startup. The app could open, show pending work, and explain which operations awaited shared context.
External reconciliation ran after the connection was available and produced a new durable operation or receipt. It did not rewrite the migration history as if the context had always been local.
Deployments that require network access before reading local offline work are offline only under ideal conditions.
Transactions completed after requests did
IndexedDB's request callbacks could succeed before the surrounding transaction completed. My first migration helper resolved its promise-like wrapper when the final put request succeeded. The transaction could still abort afterward.
I changed success to mean transaction completion. Individual request errors were captured with their operation identity, but the database was not declared upgraded or the record migrated until the transaction's complete event fired.
Similarly, throwing inside a callback needed to abort the transaction deliberately and reach one failure path. A console error while the transaction continued could commit a partially interpreted result.
The wrapper exposed three outcomes:
complete — all writes committed
abort — no transaction writes committed
blocked — version change has not begunBlocked was not the same as failed. Retrying without closing the old connection would repeat the wait. The interface guided the relevant action.
This discipline prevented a subtle loss: removing an old record after a new-store put request succeeded but before the transaction eventually aborted. Both changes belonged in one transaction and completion owned the claim.
Adding an index assumed existing records contained valid index keys. Early sample data did; an old malformed record did not.
Before the production-like migration, I copied fixtures representing every historical stored shape into a test database and opened the new version. The upgrade had to account for missing keys, duplicate values under a new uniqueness constraint, and types IndexedDB would not accept as keys.
Where uniqueness was a new product requirement, I did not impose a unique index before resolving existing collisions. The migration first created a non-unique diagnostic path or a parallel normalized store, then the application surfaced conflicting records. A later version could enforce uniqueness after cleanup.
For optional lookup fields, records without a key simply did not appear in that index. The main store remained the source for recovery. I avoided deleting a pending operation merely because it could not participate in a convenience query.
An index is derived access structure. It should not outrank the record it helps find.
Historical fixtures made the upgrade operate on the database that existed, not the database the new code wished had existed.
One proposed schema split the outbox into operation and receipt stores. Moving records in place would require deleting old envelopes as new records were written.
I used a copy-then-switch approach within the version-change transaction. New stores received validated copies first. A small migration marker recorded completion. Only after every eligible record copied successfully did the upgrade retire the old store.
Records that could not be converted aborted the upgrade or moved into an explicit recovery store, depending on whether their original bytes could be preserved intact. They were never silently skipped.
The temporary duplication increased local storage during migration. The preflight estimated whether there was enough room and kept the old database usable if not. On constrained devices, an export-and-recover path was safer than beginning a move that could not finish.
IndexedDB's schema operations happened within the version-change transaction, so the exact mechanics required care. The conceptual invariant remained simple: do not destroy the only representation until the replacement and its identity are durably part of the same committed upgrade.
The migration optimized for recoverability, not minimum temporary bytes.
A new JavaScript bundle could open database version four while an older cached page still expected version three. The versionchange handler made the old connection close, but its UI code might continue trying to use a closed database.
The old tab entered a terminal “update required” state after preserving draft input. It did not reopen the database at version three and block the new page again.
The new application understood migrated legacy record versions. That allowed code deployment and lazy record conversion to overlap safely. It did not assume every record had already been rewritten by the version transaction.
I tested these sequences:
- Old tab open, new tab requests upgrade.
- New tab upgrades, old tab returns from background.
- Upgrade aborts, old tab remains usable on old schema.
- Device skips two application releases.
- Browser closes during a normal post-upgrade record migration.
The deployment plan included user-visible behavior for each. “Works after clearing data” did not qualify.
Local schema compatibility became part of release engineering.
Backups were portable operations, not database files
Browser tooling could sometimes inspect or copy IndexedDB, but X8B6 needed a recovery format people could deliberately save before a risky upgrade.
The export contained pending operations, local drafts, relevant receipts, schema versions, and a manifest. It did not serialize browser-internal object store structures or derived indexes.
export version
created at
application origin
operations with stable identities
receipts with confirmed revisions
drafts with local identifiers
integrity countsImport validated every record, checked for existing operation identities, and never submitted work automatically. It first restored into a reviewable local outbox.
The export was not marketed as perfect backup of the whole application. It protected the unique intent that could not be fetched elsewhere. Confirmed projections could return from the server.
Designing the format clarified the database itself. Anything essential that could not be exported with meaning was probably coupled too closely to a storage implementation.
Migration tests started from history
An empty-database test proved only installation. I kept fixture builders for every shipped schema version and meaningful broken case.
Each test created the old database through its actual schema rules, inserted pending operations, receipts, and malformed-but-previously-accepted records, closed the connection, then opened with the new version. Assertions checked both the new schema and the semantic survival of work.
The strongest assertion was not record count alone:
operation identity unchanged
declared intent unchanged or explicitly marked legacy
pending status preserved
receipt relationship preserved
derived projection rebuildable
old connection receives version-change requestI also simulated an abort during transformation and reopened at the old version. The old store needed to remain intact. A retry of the upgrade needed to produce the same result without duplicated records.
These tests grew with every release. Historical schema became supported input, not an embarrassing artifact to delete from fixtures.
Recovery UI was part of the migration
Not every problem could be solved automatically. A blocked tab, insufficient storage, unknown future export, or ambiguous legacy operation needed a person to choose.
The recovery screen used plain distinctions:
Your unsent work is still stored in the previous local format.
The update has not changed it.
Options:
— close another open X8B6 tab and try again
— export unsent work
— continue with the previous version in this tabThe exact options depended on the failure. The screen never suggested deleting storage as the first remedy. It showed the number of pending operations at risk and whether drafts were included in the export.
Diagnostics offered database version, failed migration step, and anonymous record identifiers without dumping inventory notes into an error report.
Building this screen forced migration failures to be classified. A generic catch handler could not support a truthful recovery choice.
A browser upgrade was a deployment
IndexedDB made offline storage possible and also placed a database deployment inside ordinary page startup. The migration could be blocked by another tab, abort as a transaction, encounter years-old record shapes, or run on the only copy of unsent work.
X8B6's safer process came from a few rules:
- Classify local data by recoverability.
- Preserve record-level schema versions.
- Keep version-change work deterministic and network-independent.
- Close old connections deliberately across tabs.
- Copy before retiring unique representations.
- Declare success only when the transaction completes.
- Test from every historical schema with pending work.
- Offer export and review before destructive reset.
The database remained small. The consequence of one lost record was not.
I stopped seeing a browser schema bump as maintenance hidden behind onupgradeneeded. It was a release crossing state somebody had already entrusted to the application. The migration's first responsibility was to carry that trust forward intact.