Skip to content
R.

How do I make an IndexedDB upgrade/migration atomic and recoverable across versionchange and transaction aborts?

Use an IndexedDB versionchange transaction as the only place schema and record moves happen, then add a durable migration marker and recovery check so an interrupted upgrade can be retried safely instead of leaving a half-moved database behind.

About 15 min readComments

IndexedDB upgrades are one of those places where the browser’s transaction model and the application’s data model meet, and the mismatch is easy to miss. A versionchange transaction can protect schema work, but a migration that moves records into a new store still needs a recovery story if the transaction aborts partway through.

This article uses the documented open-and-upgrade flow, the versionchange transaction mode, and the transaction failure callbacks to build a small but useful pattern: move records atomically when the upgrade succeeds, and when it does not, leave behind enough evidence to detect the partial state and retry safely on the next open.

The failure is a partial upgrade, not a bad rename

IndexedDB migrations fail in a way that is easy to underestimate. The schema change is not just a new object store or a renamed one; it is also the movement of existing records into the new shape. If the browser aborts the upgrade transaction after you have created the destination store but before every record has been copied, the database can be left in a state that is neither the old schema nor the new one.

That matters because the open-and-upgrade flow is transactional only inside the versionchange work you perform in the upgrade callback. MDN’s IndexedDB guide makes the key point: schema changes belong in the upgrade event, and the transaction mode for those changes is versionchange. MDN’s IDBTransaction page also makes clear that transaction failures can come from explicit aborts, request errors, uncaught exceptions, and other failures that trigger onabort. A safe migration has to assume that one of those endings can happen at any point before completion.

The practical question is not whether the browser can fail. It can. The question is what state you leave behind when it does. If the migration is written so that the destination becomes visible only when the copy is complete, then an abort can roll back the transaction state. If the application also writes a durable marker that says, “an upgrade was in progress,” then the next open can distinguish a clean database from an interrupted one and decide whether to retry or clean up.

main.mjs / javascript
// Simulation of an IndexedDB-style versionchange migration that is
// atomic and recoverable. THIS IS A NODE-IN-MEMORY SIMULATOR, not
// real IndexedDB. It models the important behaviors (versionchange
// transaction, onabort/oncomplete, durable migration marker) so the
// article can demonstrate detection of partial upgrades and a retry
// path that either completes or rolls back the migration.
//
// Public API:
// - createFixture(stores): returns an in-memory DB object { stores, meta }
// - migrateMove(db, fromName, toName, { shouldAbortDuringMove })
//     Performs a versionchange-style migration that moves all records
//     from `fromName` to `toName` atomically. It writes a durable
//     marker (meta._migration) to indicate progress; if aborted, the
//     marker persists to simulate a partially-applied upgrade.
// - detectAndRecover(db, fromName, toName): looks for partial
//     migration and either rolls back or completes the move so the DB
//     ends up consistent.
//
// This simulator intentionally exposes the metadata needed by the
// recovery algorithm so tests can assert the exact recovery behavior.

export function createFixture(stores = {}) {
  // stores: { name: { [key]: value } }
  const clone = JSON.parse(JSON.stringify(stores));
  return {
    stores: clone,
    // meta simulates on-disk migration markers that survive an abort.
    meta: {}
  };
}

// Internal helper to deep clone store contents
function cloneStore(store) {
  return JSON.parse(JSON.stringify(store));
}

// Perform a migration that moves all records from an existing store
// `fromName` into a new store `toName` inside a single "versionchange"
// transaction. The transaction can be forced to abort mid-transfer by
// setting shouldAbortDuringMove to true. When the transaction starts
// we write a durable meta marker { migrating: { from,to } } before
// starting to move records to simulate on-disk progress visibility.
// If the transaction aborts, the stores must be left as if the
// transaction never happened (rolled back), but the durable marker
// may remain to indicate partial progress — this models a browser
// crash between steps. A real IndexedDB upgrade should ensure the
// onupgradeneeded handler leaves a detectable and recoverable state.
export function migrateMove(db, fromName, toName, options = {}) {
  const { shouldAbortDuringMove = false } = options;

  if (!db.stores[fromName]) {
    throw new Error('source store not found: ' + fromName);
  }
  if (db.stores[toName]) {
    throw new Error('target store already exists: ' + toName);
  }

  // Begin "versionchange transaction"
  const tx = {
    aborted: false,
    onabort: null,
    oncomplete: null
  };

  // Durable marker written at the start of upgrade, visible across
  // crashes/aborts in this simulation.
  db.meta._migration = { state: 'in-progress', from: fromName, to: toName };

  // Create the new store but buffer its contents locally (not yet
  // committed) to simulate transaction-local writes.
  const stagedTo = {};

  try {
    const source = db.stores[fromName];
    const keys = Object.keys(source);

    for (let i = 0; i < keys.length; i++) {
      const k = keys[i];
      // copy value into staged target
      stagedTo[k] = cloneStore(source[k]);

      // Optionally abort mid-transfer to simulate a thrown exception
      if (shouldAbortDuringMove && i === Math.floor(keys.length / 2)) {
        throw new Error('simulated mid-migration crash');
      }
    }

    // If we reach here, commit the staged writes atomically.
    db.stores[toName] = stagedTo;
    // Remove source store to model a move
    delete db.stores[fromName];

    // Clear durable marker and mark transaction complete
    delete db.meta._migration;
    tx.aborted = false;
    if (tx.oncomplete) tx.oncomplete();
    return { committed: true };
  } catch (err) {
    tx.aborted = true;
    if (tx.onabort) tx.onabort(err);

    // On abort, we must leave the logical stores as if transaction
    // never happened — rollback staged changes. We intentionally
    // keep db.meta._migration to simulate a partially applied
    // on-disk marker that recovery must inspect.
    // Note: we do NOT delete db.meta._migration here to simulate a
    // crash that occurred after the marker was written but before
    // the migration completed.

    return { committed: false, error: err };
  }
}

// Detect a partially-applied migration and recover it. Recovery policy:
// - If meta indicates an in-progress migration from->to and the target
//   store exists, assume commit succeeded and remove the marker.
// - If meta indicates in-progress but target doesn't exist, it means
//   the migration didn't finish. In that case, we rollback any remnants
//   and retry the migration to complete it atomically.
// This function returns an object describing what it did.
export function detectAndRecover(db, fromName, toName, options = {}) {
  const m = db.meta._migration;
  if (!m || m.from !== fromName || m.to !== toName) {
    return { recovered: false, reason: 'no-migration-marker' };
  }

  const targetExists = Boolean(db.stores[toName]);
  const sourceExists = Boolean(db.stores[fromName]);

  if (targetExists && !sourceExists) {
    // Looks like commit succeeded but marker wasn't cleared.
    delete db.meta._migration;
    return { recovered: true, action: 'cleared-marker' };
  }

  if (!targetExists && sourceExists) {
    // Partial: migration marker exists but nothing committed. Retry
    // the migration but ensure it completes atomically.
    const retry = migrateMove(db, fromName, toName, options);
    if (retry.committed) return { recovered: true, action: 'reapplied' };
    return { recovered: false, action: 'retry-failed', error: retry.error };
  }

  // Edge case: both exist (duplicate). Prefer target as canonical
  // (assume it committed), remove source and marker.
  if (targetExists && sourceExists) {
    delete db.stores[fromName];
    delete db.meta._migration;
    return { recovered: true, action: 'removed-source-presumed-commit' };
  }

  return { recovered: false, reason: 'unknown-state' };
}

SourcesMDN: Using IndexedDB (opens a new tab)MDN: IDBTransaction (opens a new tab)

Why the versionchange transaction must own the move

The important ownership rule is that the upgrade callback is the only place where the database structure changes. That is not just a stylistic preference. The open() call with a higher version number triggers onupgradeneeded, and that callback is where you create the new store, remove the old one, and move the records while the versionchange transaction is still in control. Once you leave that callback, the browser has no obligation to keep the schema change open for you.

The example fixture models this by doing all structural work in a single migrateMove() call. It creates the destination store only inside the upgrade path, buffers writes until the move is complete, and then either commits the move or leaves the source store untouched if the transaction aborts. That structure mirrors the contract MDN describes: versionchange is the mode for schema work, while readwrite is for ordinary data updates after the database already exists.

This distinction matters because a common incomplete fix is to create the new store in onupgradeneeded and then schedule the data copy later in a normal readwrite transaction. That splits one logical migration into two separate failure windows. If the browser closes, the tab crashes, or a transaction aborts between those windows, the destination schema exists but the data move is incomplete. Keeping the entire move inside the versionchange transaction narrows the state space to just two useful outcomes: everything moved, or nothing committed.

SourcesMDN: Using IndexedDB (opens a new tab)

The example uses a marker because abort is not enough by itself

A transaction abort protects the atomicity of the records it owns, but it does not tell the application whether a migration had already started before the failure. That distinction is why the fixture writes a durable marker before the move begins. The marker says, “this upgrade was in progress,” and it survives the simulated abort so that the next open can recognize a partially applied migration.

The marker is not the migration itself. It is the recovery hint. In the fixture, the move writes to db.meta._migration at the beginning of the versionchange work, then copies all records into a staged destination object, and only after that does it swap the store and clear the marker. If an error is thrown halfway through, the transaction reports failure through the abort path, the source store remains intact, and the marker remains as evidence that a retry is needed. That is the central lesson: recovery needs a separate piece of state that can outlive the failed attempt.

This is also why the article does not claim that every IndexedDB application needs a custom marker. If your migration is tiny and can be re-derived cheaply from the old store, you may be able to rely only on the transaction rollback and simply try again on the next open. But once the migration spans multiple versions, renames, or irreversible transforms, a durable marker gives the application a way to distinguish “never started” from “started and aborted.”

SourcesMDN: IDBTransaction (opens a new tab)

What the retry path checks before it runs again

A recoverable migration needs a rule for deciding whether to re-run work or just clean up. The fixture’s detectAndRecover() function uses the marker plus the visible stores to choose between three cases. If the marker exists and the destination store does not, it treats the migration as interrupted and reruns the move. If the marker exists and the destination store does exist while the source does not, it treats the migration as effectively committed but the marker as stale, so it clears the marker and stops. If both source and destination exist, it prefers the destination as canonical, removes the source, and clears the marker.

That logic is intentionally conservative. It does not guess that a half-finished data shape is valid just because one object store was created. It checks the actual stores that matter to the application and only repairs the state that is provably incomplete. This is important because IndexedDB failures can be noisy: onabort tells you the transaction failed, but it does not by itself tell you which step in the migration had already been attempted before the failure. The application needs a policy that is explicit about what counts as a recoverable state.

The safe retry pattern is therefore not “retry blindly.” It is “inspect the durable marker, inspect the stores, then choose the smallest action that restores one consistent schema.” In the fixture, that smallest action is either rerunning the migration or deleting the stale marker. In a real app, the same rule keeps the recovery path short and predictable.

SourcesMDN: IDBTransaction (opens a new tab)MDN: Using IndexedDB (opens a new tab)

What the test suite proves, and what it cannot prove

The included Node tests prove the migration policy encoded in the fixture, not the browser itself. They check that a successful move clears the marker and removes the source store, that a simulated abort leaves a marker behind, and that recovery can reapply the migration after an incomplete attempt. They also check the stale-marker case where the destination exists and the marker should simply be cleared. Those assertions are useful because they pin down the application’s own recovery contract.

What they do not prove is the behavior of every browser failure path. They do not measure disk flush timing, crash recovery after an operating-system failure, or the exact moment a specific browser emits onabort versus oncomplete under every condition. MDN documents the kinds of failures that can abort a transaction, but browser shutdown, power loss, and storage subsystem failures still belong to the operational world rather than the fixture. That is why the article treats the example as a controlled model of the application-owned recovery algorithm, not as a claim about full durability under all machine failures.

This separation keeps the example honest. The code can prove that your recovery logic handles a partial migration cleanly. It cannot prove that the browser will always finish a transaction after a crash. The useful conclusion is narrower and stronger: if the browser aborts the upgrade, the application can detect that interruption and put the database back into one of the states it knows how to serve.

main.test.mjs / javascript
import test from 'node:test';
import assert from 'node:assert/strict';
import { createFixture, migrateMove, detectAndRecover } from './main.mjs';

// Success case: simple move completes and leaves DB consistent.
test('migration commits successfully and clears marker', () => {
  const db = createFixture({ a: { '1': { v: 'one' }, '2': { v: 'two' } } });
  const res = migrateMove(db, 'a', 'b', { shouldAbortDuringMove: false });
  assert.equal(res.committed, true, 'expected migration to commit');
  assert.ok(!db.meta._migration, 'marker should be cleared after commit');
  assert.ok(!db.stores.a, 'source store removed after move');
  assert.deepEqual(db.stores.b['1'], { v: 'one' });
});

// Failure / boundary case 1: simulated mid-migration abort leaves a
// durable marker; recovery should detect and retry to finish the move.
test('simulated abort leaves marker and recover retries to apply', () => {
  const db = createFixture({ users: { 'u1': { name: 'A' }, 'u2': { name: 'B' } } });

  // Simulate abort during migration. This will set meta._migration and
  // return committed: false.
  const res = migrateMove(db, 'users', 'users_v2', { shouldAbortDuringMove: true });
  assert.equal(res.committed, false, 'expected simulated abort');
  assert.ok(db.meta._migration, 'durable marker present after abort');

  // The source should still exist because transaction aborted
  assert.ok(db.stores.users, 'source store must still exist after abort');
  assert.ok(!db.stores.users_v2, 'target store should not have been created on abort');

  // Now run recovery which will retry the migration and complete it.
  const rec = detectAndRecover(db, 'users', 'users_v2', { shouldAbortDuringMove: false });
  assert.equal(rec.recovered, true, 'recovery should succeed');
  assert.equal(rec.action, 'reapplied', 'recovery should reapplied the migration');
  assert.ok(!db.stores.users, 'source removed after successful recovery');
  assert.ok(db.stores.users_v2, 'target exists after recovery');
  assert.deepEqual(db.stores.users_v2['u2'], { name: 'B' });
});

// Failure / boundary case 2: marker present but target exists (commit
// probably succeeded); recovery should detect and simply clear the marker.
test('marker present but target exists -> clear marker (idempotent)', () => {
  const db = createFixture({});
  // Simulate a state where migration marker exists but commit already
  // completed (e.g., browser cleared store but crashed before
  // removing marker). We'll create target and remove source to
  // model that.
  db.stores.newstore = { 'x': { hello: 'world' } };
  db.meta._migration = { state: 'in-progress', from: 'old', to: 'newstore' };

  const rec = detectAndRecover(db, 'old', 'newstore');
  assert.equal(rec.recovered, true, 'should clear stale marker');
  assert.equal(rec.action, 'cleared-marker');
  assert.ok(!db.meta._migration, 'marker removed');
  assert.ok(db.stores.newstore, 'target preserved');
});

SourcesMDN: IDBTransaction (opens a new tab)

When this pattern is the right size

This pattern fits migrations where the old and new schemas are both understandable at open time and where the application can tell whether the move was completed. It works well for renaming an object store, splitting one store into two, or moving records into a new structure that can be derived deterministically from the old data. It is also a good fit when the upgrade has to be repeatable after a user closes the tab or the browser aborts the transaction before completion.

It is a poorer fit when the migration depends on external services or on data that can change while the migration is in flight. In those cases, a retry may not be idempotent, so the application needs a more elaborate reconciliation strategy than “inspect marker, rerun move.” The fixture deliberately avoids those extra variables. It uses only local stores, one upgrade callback, one recovery check, and one clearly visible failure boundary.

The final lesson is that atomicity and recoverability are related but not identical. Atomicity says the transaction should not leave a half-moved database. Recoverability says the application can recognize that it has to try again and can do so without inventing a second inconsistent state. IndexedDB gives you the transaction semantics; the application has to supply the recovery rule. When those two pieces are aligned, a versionchange migration becomes something you can interrupt, observe, and safely retry.

SourcesMDN: Using IndexedDB (opens a new tab)MDN: IDBTransaction (opens a new tab)

Share LinkedIn Email Subscribe

Discussion

Leave a comment

Comments appear after review. No email needed.

Image preview

Follow the blog

Choose email updates or your favorite RSS reader.

The weekly email

New articles on code, design, and AI. Fridays, only when there is something new.

By subscribing, you agree to weekly blog emails.
Confirm in your inbox. Unsubscribe anytime. Privacy

Use OpenRSS

Preview the feed, then choose a reader to subscribe.

Open in OpenRSS (opens a new tab)

Already have a reader?

Paste this link into your reader's Add feed option.

Prefer a reader? RSS does not require an email address. View XML feed