Frontend developer troubleshooting a blocked IndexedDB schema upgrade across multiple browser tabs

Upgrade IndexedDB Without Leaving Old Tabs Behind

Last updated: Mon, Sep 21, 2026

A user edits the same draft application in two tabs. Tab A still runs schema version 1, holds an open database connection, and contains a memory-only edit. Tab B loads the new application revision and calls indexedDB.open("DraftDB", 2). The request cannot start the version 2 upgrade while Tab A keeps its version 1 connection open. A green page shell in Tab B is therefore not proof that the database migrated, and reloading Tab A before protecting its in-memory edit can still lose user work.

This playbook follows the connection and transaction protocol instead of trusting the build, a loading screen, or one successful request. It records the blocked event on the opener, the versionchange event on existing connections, the upgrade transaction outcome, the final database version, and the draft ledger. The result is a controlled Proceed, Hold, or Recover decision. The examples use native IndexedDB in one origin and one browser storage partition. They exclude service-worker activation, back-forward cache behavior, offline synchronization, wrapper-library comparisons, and deletion of production user data.

Define Success for Data and Open Tabs

Success has two independent parts. Persisted state must move from the version 1 contract to the version 2 contract atomically, and unsaved state outside IndexedDB must survive the coordination step. The test therefore treats a visible updated tab as an interface event, not a database oracle. Approval belongs to the release owner or reviewer named in the run record, and the application must have a bounded policy for a blocked request instead of waiting silently forever.

Acceptance criterion

Required evidence

Persisted drafts preserved

Draft IDs 1, 2, and 3 remain readable with the expected revisions and payloads.

Version 2 schema committed

Database version is 2, every record has a status value, and the non-unique byStatus index answers a real query.

Unsaved edit protected

Tab A retains or restores the revision 4 memory-only edit without silently overwriting revision 3.

Open tabs handled deliberately

Each connection owner closes, remains blocked, or is marked stale according to policy.

User interruption bounded

The interface explains waiting, failure, and refresh actions without claiming data loss that has not been observed.

These invariants complement broader guidance on browser-side storage within API-driven applications. The upgrade may be approved only when the schema evidence and the unsaved-draft evidence agree. If one is missing, the correct result is Hold, even when the page appears usable.

Choose an operational threshold that fits the application, then record it as test input. For example, a test may classify the request as operationally blocked after five seconds, but that value is a local policy threshold, not a browser guarantee. Reaching the threshold triggers user guidance and evidence capture. It must not trigger database deletion or a forced reload that discards the open draft.

Pin the Browser, Origin and Application Revisions

A reproducible run identifies the exact browser build, operating system, application revisions, database name, schema versions, and automated test-runner revision. Both pages must share one real browser profile or one automated browser context. Opening Tab A and Tab B in separate isolated contexts can produce two independent storage partitions and accidentally remove the conflict the test is intended to exercise.

Run

Browser build

OS

Origin

Tab A

Tab B

Manual example

Chrome 116.0.5845.97

Windows 10

app.example.com

App 1.0 / DB v1

App 2.0 / DB v2

Cross-browser example

Firefox 115

Ubuntu

app.example.com

App 1.0 / DB v1

App 2.0 / DB v2

The examples are run-manifest values, not claims that those builds were executed for this article. Replace them with the exact builds and runner revision used in the acceptance run. The two pages must also use the same scheme, host, port, browser profile, and storage partition. This is an application-state test, so the environment controls are as important as the JavaScript. The same principle appears in frontend application-state foundations, where state ownership must be explicit across application surfaces.

The IndexedDB opening and upgrade algorithms define the governing boundary: an upgrade transaction is exclusive, and the upgradeneeded event does not start until other connections to that database have closed. Record the observed implementation and version if a browser differs from the draft. Do not merge results from different builds into one undifferentiated pass.

Prepare the Version-One Draft Ledger

Seed a disposable version 1 database with three records whose IDs, revisions, and payloads are known before the test begins. Keep one edit only in Tab A memory so the harness can distinguish committed storage from work that still belongs to the user interface. The seed step should fail loudly if a request or transaction fails, and it should leave the version 1 connection open in Tab A.

const DB_NAME = "DraftDB";
let dbA;

const openV1 = indexedDB.open(DB_NAME, 1);
openV1.onupgradeneeded = (event) => {
  const db = event.target.result;
  const tx = event.target.transaction;
  const drafts = db.createObjectStore("drafts", { keyPath: "id" });
  drafts.createIndex("byRevision", "revision", { unique: false });

  drafts.add({ id: 1, revision: 1, title: "First draft", body: "Hello" });
  drafts.add({ id: 2, revision: 3, title: "Second draft", body: "World" });
  drafts.add({ id: 3, revision: 7, title: "Third draft", body: "Foo" });

  tx.onabort = () => console.error("v1 seed aborted", tx.error);
};
openV1.onsuccess = () => {
  dbA = openV1.result;
};
openV1.onerror = () => console.error("v1 open failed", openV1.error);

Tab A then holds one unsaved edit that has not been written to the drafts store:

const unsavedDraft = {
  id: 2,
  revision: 4,
  title: "Second draft (edited)",
  body: "World!!"
};

ID

Persisted revision

Title

Body

1

1

First draft

Hello

2

3

Second draft

World

3

7

Third draft

Foo

The persisted ledger contains revision 3 for draft 2. Tab A memory contains revision 4. That difference is intentional. If a later reconciliation shows revision 4 in IndexedDB twice, or shows only revision 3 with no recoverable edit, the recovery path has duplicated or lost work.

Record Every Connection Owner

Before requesting version 2, list every object that can own an open connection. The baseline has no workers, but a worker added later becomes another owner that must receive its own closure test. Do not assume a tab is the only owner merely because it is the only visible page.

  • Tab A: App 1.0 holds dbA connected to DraftDB version 1 and owns the memory-only edit.

  • Tab B: App 2.0 has not yet opened DraftDB.

  • Workers: None in this baseline; any later worker must be logged and closed explicitly.

The connection ledger should include tab ID, app revision, requested version, observed database version, handle state, active transaction state, and a digest of the persisted and memory-only drafts. This makes the eventual decision inspectable instead of dependent on console recollection.

Trace a Version-Two Open Request

Tab B requests version 2 and attaches every relevant handler before the browser can dispatch the event. The application-ready promise must not resolve when the request is created or when a blocked message is shown. It resolves only after the open request succeeds, and acceptance still requires a fresh schema and data inspection.

const eventLedger = [];
const recordEvent = (tab, type, details = {}) => {
  eventLedger.push({ tab, type, at: performance.now(), ...details });
};

const openV2 = indexedDB.open(DB_NAME, 2);
openV2.onblocked = (event) => {
  recordEvent("B", "blocked", {
    oldVersion: event.oldVersion,
    newVersion: event.newVersion
  });
};
openV2.onupgradeneeded = (event) => {
  const tx = event.target.transaction;
  recordEvent("B", "upgradeneeded", { oldVersion: event.oldVersion });
  tx.oncomplete = () => recordEvent("B", "upgrade-complete");
  tx.onabort = () => recordEvent("B", "upgrade-abort", { error: tx.error?.name });
  // The migration body is added in the schema-change section below.
};
openV2.onsuccess = () => {
  recordEvent("B", "open-success", { version: openV2.result.version });
};
openV2.onerror = () => {
  recordEvent("B", "open-error", { error: openV2.error?.name });
};

Distinguish Notification From Commit

Each signal answers a different question. A notification that another client wants a new version is not evidence that the upgrade transaction started, and a successful individual request inside the transaction is not evidence that the transaction committed.

Signal

What it proves

versionchange event in Tab A

Another context requested an upgrade or deletion. Tab A has an opportunity to protect work and close.

blocked event in Tab B

At least one earlier connection is still preventing the versionchange transaction from starting.

upgradeneeded in Tab B

Other version 1 connections have closed and the exclusive upgrade transaction has started.

upgrade transaction complete

All requests in the upgrade transaction completed and its database changes committed.

open request success

Tab B received an open connection at the requested current version after any required upgrade.

The multi-tab version-change guidance describes the same coexistence problem in practical terms. The acceptance ledger should preserve event order without assuming a universal delay between events. If upgradeneeded or open-success appears while Tab A is still recorded as open, inspect the test isolation and instrumentation before trusting the result.

Reproduce a Blocked Upgrade Without Losing Data

For the noncooperating control, leave dbA open and request version 2 in Tab B. The expected state is waiting: Tab B records blocked, but it records neither upgradeneeded nor open-success during the local observation window. Tab A remains able to read the version 1 records, and its unsaved edit remains in memory.

openV2.onblocked = (event) => {
  recordEvent("B", "blocked", {
    oldVersion: event.oldVersion,
    newVersion: event.newVersion
  });
  showUpgradeStatus("Waiting for another tab to finish editing.");
};

Use Tab A to verify the committed baseline while its connection is still open:

const verifyV1 = dbA.transaction("drafts", "readonly");
const countRequest = verifyV1.objectStore("drafts").count();
countRequest.onsuccess = () => {
  recordEvent("A", "v1-count", { count: countRequest.result });
};
verifyV1.onabort = () => {
  recordEvent("A", "v1-read-abort", { error: verifyV1.error?.name });
};

Tab

Observed DB version

Record evidence

Connection state

A

1

Count is 3; draft 2 remains revision 3 in storage

Open and readable

B

No connection yet

Not readable through the pending open request

Open request blocked

This is the misleading-success control. The new application shell may be visible, but the database is still version 1 and the migration body has not run. After the bounded observation window, classify the run as Hold and preserve the ledger. Do not convert the timeout into a claim that the browser violated a deadline; the timeout belongs to the application policy.

Make Existing Tabs Cooperate With Closure

A cooperating Tab A handles versionchange by stopping admission of new database work, preserving the unsaved edit through a deliberate application path, and calling close on the existing connection. Closing a connection marks it close-pending. Existing transactions are allowed to finish or abort, but no new transaction may be created from that handle.

let acceptingDatabaseWork = true;

dbA.onversionchange = (event) => {
  acceptingDatabaseWork = false;
  recordEvent("A", "versionchange", {
    oldVersion: event.oldVersion,
    newVersion: event.newVersion
  });

  sessionStorage.setItem(
    "DraftDB:pending-edit",
    JSON.stringify(unsavedDraft)
  );

  dbA.close();
  recordEvent("A", "close-requested");
  showUpgradeStatus("This tab is ready to refresh after the database upgrade.");
};

The database connection close algorithm waits for transactions created on that connection to finish after the close-pending flag is set. It also prevents new transactions from being created. The application should therefore stop accepting writes before calling close rather than trying to save the draft through a new IndexedDB transaction afterward.

Separate Connection Closure From Draft Preservation

Closing IndexedDB and protecting the editor buffer are different operations. The example uses sessionStorage only as a compact same-tab handoff for the lab. A production application may use another reviewed mechanism, but it must record ownership, conflict policy, and restoration behavior. The user interface must not reload until that handoff has succeeded or the user has chosen how to proceed.

  • Stop admission: Reject or queue new database commands once versionchange begins.

  • Protect the draft: Copy the memory-only revision to the approved recovery channel and verify the copy can be read back.

  • Close the handle: Call dbA.close() once; do not create a replacement v1 connection in the same stale tab.

  • Wait for evidence: Allow Tab B to log upgradeneeded, transaction completion, and open-success before offering refresh.

This sequence follows MDN’s version-change handling example while making the unsaved-work boundary explicit. A forced reload before preservation may close the connection, but it does not satisfy the data-protection requirement.

Keep the Schema Change Inside Its Transaction

All version 2 schema work belongs in the upgradeneeded transaction. The migration creates a non-unique byStatus index and backfills status for every existing draft. It branches on oldVersion so each supported starting version has a deliberate path. The example does not use fetch, timers, or unrelated promises as a keep-alive mechanism.

openV2.onupgradeneeded = (event) => {
  const tx = event.target.transaction;
  recordEvent("B", "upgradeneeded", { oldVersion: event.oldVersion });

  tx.oncomplete = () => recordEvent("B", "upgrade-complete");
  tx.onabort = () => recordEvent("B", "upgrade-abort", {
    error: tx.error?.name
  });

  if (event.oldVersion < 1) {
    throw new Error("This migration expects an existing version 1 database.");
  }

  if (event.oldVersion < 2) {
    const drafts = tx.objectStore("drafts");
    drafts.createIndex("byStatus", "status", { unique: false });

    const cursorRequest = drafts.openCursor();
    cursorRequest.onsuccess = () => {
      const cursor = cursorRequest.result;
      if (!cursor) return;

      const record = { ...cursor.value, status: "new" };
      cursor.update(record);
      cursor.continue();
    };
  }
};

The IDBTransaction lifecycle documentation explains why an unrelated await is unsafe: a transaction alternates between active and inactive states across event-loop tasks and automatically commits when no requests remain. Issue IndexedDB requests while the transaction is active, and treat tx.oncomplete or tx.onabort as the final transaction signal. A complete event proves the API-level transaction outcome; it should not be marketed as a universal crash-proof durability guarantee.

  • Schema assertion: byStatus exists and is non-unique.

  • Backfill assertion: all three drafts contain status = "new" after a fresh open.

  • Failure assertion: any unhandled request error or thrown handler exception produces upgrade-abort and open-error, not a ready state.

Abort a Broken Migration and Inspect the Result

Exercise a genuine transaction failure in a separate disposable database. Seed two records with the same candidateKey, close the seed connection, then request version 2 and create a unique index over that field. The createIndex call can return an index handle before index population discovers the duplicate keys, so a synchronous return is not success evidence.

const FAILURE_DB = "DraftDBAbortFixture"; // disposable test data only
const seedFailure = indexedDB.open(FAILURE_DB, 1);

seedFailure.onupgradeneeded = () => {
  const drafts = seedFailure.result.createObjectStore("drafts", {
    keyPath: "id"
  });
  drafts.add({ id: 1, candidateKey: "duplicate", body: "Alpha" });
  drafts.add({ id: 2, candidateKey: "duplicate", body: "Beta" });
};

seedFailure.onsuccess = () => {
  seedFailure.result.close();

  const failOpen = indexedDB.open(FAILURE_DB, 2);
  failOpen.onupgradeneeded = (event) => {
    const tx = event.target.transaction;
    tx.onabort = () => recordEvent("B", "fixture-upgrade-abort", {
      error: tx.error?.name
    });

    tx.objectStore("drafts").createIndex(
      "byCandidateKey",
      "candidateKey",
      { unique: true }
    );
  };
  failOpen.onerror = () => recordEvent("B", "fixture-open-error", {
    error: failOpen.error?.name
  });
  failOpen.onsuccess = () => {
    failOpen.result.close();
    throw new Error("Failure fixture unexpectedly committed version 2.");
  };
};

After the open request reports error, reopen the fixture without requesting a new version and inspect the committed state. Keep this verification separate from the failure event handler so the oracle is a fresh connection.

const verifyAbort = indexedDB.open(FAILURE_DB);
verifyAbort.onsuccess = () => {
  const db = verifyAbort.result;
  const tx = db.transaction("drafts", "readonly");
  const drafts = tx.objectStore("drafts");
  const count = drafts.count();

  count.onsuccess = () => {
    if (db.version !== 1) throw new ErrorExpected version 1, got ${db.version});
    if (drafts.indexNames.contains("byCandidateKey")) {
      throw new Error("Aborted unique index is still present");
    }
    if (count.result !== 2) throw new ErrorExpected 2 records, got ${count.result});
  };

  tx.oncomplete = () => {
    db.close();
    indexedDB.deleteDatabase(FAILURE_DB); // fixture cleanup only
  };
};

Evidence

Expected after the aborted upgrade

Database version

1

Object store

drafts exists

Unique index

byCandidateKey does not exist

Record count

2 duplicate-key fixture records remain

Production DraftDB

Untouched by the failure fixture and its cleanup

Do Not Publish Partial Migration Success

The IndexedDB transaction failure rules distinguish request success from transaction completion, and the specification requires an aborted upgrade transaction to revert its database changes. Do not display “migration complete” because createIndex returned, one cursor update succeeded, or the UI rendered. Publish success only after transaction completion, open-success, and the fresh-open schema and record checks all agree.

  • Synchronous API error: Record the exception name and stop the migration path.

  • Asynchronous constraint failure: Expect transaction abort and open-request error, then inspect version 1 in a fresh open.

  • Cleanup: Delete only the named disposable fixture after all handles close. Never point fixture cleanup at real user storage.

Handle Old Code After the Upgrade Commits

Once version 2 commits, reloading old JavaScript does not downgrade the database. Test both ways old code might reopen it. An explicit request for version 1 fails because the existing database version is higher. An open call without a version can succeed at version 2, but old code may still make invalid assumptions about stores, indexes, or record shape.

Old-client action

Expected result

Application response

indexedDB.open("DraftDB", 1)

Open request fails with VersionError

Preserve the draft and present a controlled refresh path.

indexedDB.open("DraftDB")

Connection may open at current version 2

Check db.version and refuse to run incompatible v1 storage logic.

Blind retry of version 1

Repeats the same mismatch

Stop retrying; refresh into compatible code or use an approved compatibility path.

const oldOpen = indexedDB.open(DB_NAME, 1);
oldOpen.onerror = () => {
  if (oldOpen.error?.name === "VersionError") {
    const backup = sessionStorage.getItem("DraftDB:pending-edit");
    showStaleClientMessage({ recoverableDraft: Boolean(backup) });
  }
};

This is a runtime compatibility boundary, not a bundler result. The distinction between build-time versus runtime compatibility matters because a successful deployment cannot reverse a committed client-side schema. A stale tab should stop database work, retain its unsaved draft handoff, and load the current application revision through an explicit user-visible action.

Do not put a stale client into an automatic reload loop. A controlled refresh must verify that the draft handoff exists, explain what will happen, and restore the memory-only revision without replaying it twice. If compatibility cannot be proven, Hold the tab rather than letting v1 code write against a v2 database it does not understand.

Design the Waiting and Recovery Interface

The interface should describe the state the application actually knows. Blocked means another connection is still open, not that data has been lost. Failed means the upgrade did not complete, not that every draft is intact. Ready means the fresh-open verification passed. Use a status region for progress and a clear, keyboard-accessible control for refresh or retry.

State

Suggested message

Available action

Blocked

Database update is waiting for another tab to finish.

Review open tabs; close or refresh the stale tab after protecting its draft.

Checking

The update stopped. Checking saved drafts and schema state.

Keep editing paused while verification runs.

Failed

The database update did not complete.

Retry only after the failure cause is understood; retain the recovery copy.

Ready

Database update verified. Refresh this tab to continue.

Refresh into the current application revision.

Stale client

This tab uses an older application version.

Restore or export the unsaved edit, then refresh.

Automated accessibility checks help, but manual interaction checks beyond automated scans are still necessary. Confirm that focus reaches the action, the status change is announced, the wording does not imply unverified data loss, and keyboard users can preserve or review the draft before refresh.

Avoid the Delete-Database Shortcut

Do not use indexedDB.deleteDatabase("DraftDB") as the default way to clear a blocked production upgrade. Deletion removes the very drafts the workflow is designed to protect and can itself be blocked by open connections. A reset is acceptable only for a named disposable test database or an explicitly approved cache whose recovery source and owner are documented.

  • Real drafts: Provide a verified export, server reconciliation, or approved recovery path before any destructive action.

  • Disposable fixtures: Use unique database names and delete them only after every fixture connection closes.

  • Uncertain ownership: Hold and escalate. Do not infer that local data can be recreated.

Automate the Multi-Tab Failure Matrix

A browser-driven suite should exercise different connection behaviors in separate tests, preserving the first failure and resetting only disposable fixtures between cases. Use two pages in one browser context so they share the tested origin and storage. Assert event order and final database state through polling or explicit signals instead of fixed sleeps and a green loading screen.

Scenario

Required assertion

Cooperating Tab A, no active transaction

versionchange, close-requested, upgradeneeded, upgrade-complete, and open-success occur; fresh state is v2.

Cooperating Tab A with active transaction

Close becomes pending; the active transaction finishes; only then does upgradeneeded occur.

Noncooperating Tab A

blocked occurs; upgradeneeded and open-success do not occur during the policy window; data remains v1.

Unique-index failure fixture

Upgrade transaction aborts; open request errors; fresh open shows v1 and no new index.

Old client after commit

Explicit v1 open reports VersionError; unspecified open is detected as version 2 and storage work is stopped.

Repeated v2 open

No migration branch runs again; the connection opens at v2 and the reconciled data remains unchanged.

test("v2 request is blocked by an open v1 tab", async ({ browser }) => {
  const context = await browser.newContext();
  const tabA = await context.newPage();
  const tabB = await context.newPage();

  await tabA.goto("/lab/app-v1");
  await tabA.evaluate(() => window.lab.openVersionOneAndKeepConnection());

  await tabB.goto("/lab/app-v2");
  await tabB.evaluate(() => window.lab.requestVersionTwo());

  await expect.poll(
    () => tabB.evaluate(() => window.lab.eventTypes())
  ).toContain("blocked");

  expect(await tabB.evaluate(() => window.lab.eventTypes()))
    .not.toContain("upgradeneeded");

  const snapshot = await tabA.evaluate(() => window.lab.inspectVersionOne());
  expect(snapshot).toEqual({ version: 1, count: 3, unsavedRevision: 4 });

  await context.close();
});

When reviewing browser test plans and assertions, require each test to name its database fixture, app revisions, expected event sequence, final schema oracle, draft oracle, and cleanup. A green rerun must not erase the first attempt’s event ledger or substitute for proving that its connections and fixtures were released.

  • No sleeps as proof: A bounded timer may trigger Hold, but assertions should poll recorded state.

  • No shared reset between cases: Use independent disposable database names to prevent one failure from contaminating another.

  • No hidden owners: Fail setup when an unexpected page or worker opens the tested database.

Reconcile Persisted Records and Restored Drafts

After a successful migration, compare the complete persisted record set with the version 1 ledger and the version 2 contract. IDs, revisions, titles, bodies, status values, counts, and index behavior all matter. The memory-only revision remains a separate recovery item until the application intentionally merges or saves it.

ID

Title

Body

Persisted revision

Status after v2

Memory-only revision

1

First draft

Hello

1

new

None

2

Second draft

World

3

new

4, stored separately

3

Third draft

Foo

7

new

None

The version 2 backfill must not silently promote draft 2 to revision 4 because that revision was never committed to IndexedDB. Recovery should compare revisions, show the user any conflict, and create a legitimate new persisted revision only once. In the aborted case, the persisted table remains the version 1 ledger and the recovery copy remains separate.

Test Recovery in a Fresh Open

Close every test connection, then open the intended current version in a new connection and perform real reads through the new index. An old IDBDatabase object, a cached component value, or a console message is not the oracle.

const freshOpen = indexedDB.open(DB_NAME);
freshOpen.onsuccess = () => {
  const db = freshOpen.result;
  const tx = db.transaction("drafts", "readonly");
  const drafts = tx.objectStore("drafts");
  const allDrafts = drafts.getAll();
  const indexedDrafts = drafts.index("byStatus").getAll("new");

  tx.oncomplete = () => {
    const recoveryCopy = JSON.parse(
      sessionStorage.getItem("DraftDB:pending-edit") || "null"
    );

    window.labResult = {
      version: db.version,
      records: allDrafts.result,
      indexedRecords: indexedDrafts.result,
      recoveryCopy
    };
    db.close();
  };
};
freshOpen.onerror = () => {
  window.labResult = { error: freshOpen.error?.name };
};

Proceed requires version 2, three expected persisted records, three byStatus results for "new", and the exact revision 4 recovery copy. Hold when any item is missing or when a connection owner remains unknown. Recover when the upgrade aborted, the stale client cannot hand off its draft, or the persisted and memory-only revisions cannot be reconciled safely.

  • Persisted oracle: Fresh-open version, store names, index names, count, IDs, revisions, and payloads.

  • Draft oracle: Recovery copy ID 2, revision 4, edited title, and edited body.

  • Duplicate oracle: No second revision 4 record and no repeated replay after refresh.

Roll Out With a Compatibility Window

A rollout plan must name which application and schema combinations are supported. JavaScript rollback alone cannot downgrade a database that already committed version 2. The safe options are forward-compatible code, a reviewed forward migration, or a controlled stale-client refresh that preserves unsaved work.

Application state

Database state

Policy

App v1

DB v1

Supported only before the upgrade request; must handle versionchange and preserve drafts.

App v2

DB v1

May request and execute the reviewed v1-to-v2 migration.

App v2

DB v2

Supported steady state; run normal reads and writes through the v2 contract.

App v1

DB v2

Stale client; stop database work and require a controlled refresh or compatible bridge.

Stage delivery to a controlled population and monitor blocked duration, upgrade-complete, upgrade-abort, open-error names, stale-client detections, reconciliation failures, and draft-restoration outcomes. Stop the rollout when aborts repeat, blocked requests do not clear through the approved user flow, any record digest changes unexpectedly, or unsaved drafts cannot be restored without duplication.

  • Rollback rule: Never assume older JavaScript can safely use a newer committed schema.

  • Telemetry rule: Capture events and versions without logging private draft bodies.

  • Stop rule: Hold wider delivery until the failing app/schema combination has a reviewed recovery path.

Proceed, Hold or Recover the Upgrade

Make the release decision from the event ledger and the fresh-open reconciliation, not from the application shell. Store the decision with the run manifest so another reviewer can reproduce what changed the outcome.

Decision

Minimum evidence

Proceed

Tab A draft handoff verified; all old connections closed; upgrade transaction completed; open request succeeded at v2; three records and byStatus index verified in a fresh open; stale tab refresh tested.

Hold

Request remains blocked, an owner is unknown, event order is inconsistent, or schema/data/draft evidence is incomplete. Keep current data and preserve the ledger while investigating.

Recover

Upgrade aborted or open failed, or the draft handoff cannot be reconciled. Verify the committed version, retain the recovery copy, correct the migration or compatibility path, then rerun from a controlled fixture.

The evidence record should include browser and runner revisions, origin, storage partition, database name, app revisions, requested and observed versions, connection owners, ordered events, transaction outcome, record and index checks, unsaved-draft digest, cleanup result, reviewer, and final decision. No single event replaces that record.

For engineers building stronger JavaScript and application-state foundations, the Refonte Learning Frontend Development Program presents HTML, CSS, JavaScript, React, API integration, accessibility, performance, and practical project work. Those foundations support careful reasoning about asynchronous browser behavior and resilient interfaces. Review the program page for its current scope; this article does not claim that IndexedDB migration or this multi-tab lab is a named curriculum topic.