Running Data Migrations on onInstalled

Write MV3 storage migrations that survive worker eviction: an ordered step list, per-step commits, a readiness gate, and a failure path that does not lose user data.

Published August 1, 2026 Updated August 1, 2026 9 min read
Table of Contents

The migration works on your machine because your extension reloads with an empty profile. On a real user’s machine it starts, reshapes half the store, and the worker is evicted before the last write — and the next start runs the same transform again over data that has already been transformed. Doubling a number twice is not the same as doubling it once. This page is part of Extension Updates & Data Migration and builds a migration runner that can be interrupted at any point without corrupting anything.

Root cause: onInstalled is an event, not a transaction

chrome.runtime.onInstalled gives you an event handler in an environment that may be torn down at any await point. There is no lock, no rollback, and no way to ask the browser to hold events until you are finished. Everything that makes the migration safe has to be a property of how you write the steps.

What each migration step must guaranteeA step is safe when it is ordered, idempotent and commits its own version marker; failing any of the three produces a specific class of corruption.Can this step run twice without changing the result?YesSafe to retrycommit the version and move onEviction costs one repeatno user-visible effectNoRewrite itderive from a marker, not from the valueGuard on shape, not on version alonee.g. skip if already an arrayCannot tellWrite to a new keyleave the old one untouchedDelete the old key in a later releaseonce telemetry says nobody reads it
Idempotence is the property people skip, and the one that turns an eviction into data loss.

Step 1. Store a schema version separate from the manifest version

The manifest version changes for bug fixes that touch no data. Using it to drive migrations means every release re-enters the migration path and you have to remember which versions mattered.

 1// schema.js
 2export const CURRENT_SCHEMA = 4;
 3
 4export async function readSchemaVersion() {
 5  const { schemaVersion } = await chrome.storage.local.get("schemaVersion");
 6  // A store with data but no marker predates versioning: treat it as 0, not as fresh.
 7  if (typeof schemaVersion === "number") return schemaVersion;
 8  const everything = await chrome.storage.local.get(null);
 9  return Object.keys(everything).length > 0 ? 0 : CURRENT_SCHEMA;
10}

Execution context: Service worker module. The second branch is the one that matters on a real installed base: users who installed before you added versioning have data and no marker, and defaulting them to the current version silently skips every migration they need. A genuinely fresh install has an empty area and can jump straight to current. All three engines return {} from get(null) on an empty area, so the check is portable.

Step 2. Express each change as a named, ordered step

Keeping steps as data rather than as an if ladder makes them testable in isolation and makes the order explicit.

 1// migrations.js
 2export const STEPS = [
 3  {
 4    to: 1,
 5    name: "wrap-rules-in-array",
 6    run: async () => {
 7      const { rules } = await chrome.storage.local.get("rules");
 8      if (Array.isArray(rules)) return;                 // idempotent guard
 9      await chrome.storage.local.set({ rules: rules ? [rules] : [] });
10    },
11  },
12  {
13    to: 2,
14    name: "split-name-into-parts",
15    run: async () => {
16      const { profile } = await chrome.storage.local.get("profile");
17      if (!profile || profile.given) return;
18      const [given, ...rest] = String(profile.name ?? "").split(" ");
19      await chrome.storage.local.set({ profile: { ...profile, given, family: rest.join(" ") } });
20    },
21  },
22  {
23    to: 3,
24    name: "move-token-out-of-local",
25    run: async () => {
26      const { legacyToken } = await chrome.storage.local.get("legacyToken");
27      if (legacyToken) await chrome.storage.session.set({ auth: { access: legacyToken } });
28      await chrome.storage.local.remove("legacyToken");
29    },
30  },
31  {
32    to: 4,
33    name: "drop-orphaned-cache",
34    run: async () => {
35      const all = await chrome.storage.local.get(null);
36      const stale = Object.keys(all).filter((k) => k.startsWith("cache:v1:"));
37      if (stale.length) await chrome.storage.local.remove(stale);
38    },
39  },
40];

Execution context: Service worker. Each run re-reads what it needs rather than receiving a snapshot, so a step that resumes after eviction sees the committed state rather than a stale object. Every step opens with a guard that makes a second execution a no-op — Array.isArray, a presence check, a prefix scan that finds nothing. That guard is the whole safety property. Note that step 3 moves a credential from disk-backed local storage into memory-only session storage, which is a migration worth doing for the reasons set out in refreshing and storing access tokens securely.

Step 3. Run them with a commit after each

The runner loop and where it can be interruptedEach step runs then commits its version; an eviction between the two costs one repeated step, and an eviction after the commit costs nothing.Read schemaVersionone storage getSkip completed stepsstep.to <= currentRun one stepguarded, re-reads statethen, and only thenCommit schemaVersionset({ schemaVersion: step.to })Next step or doneloop continuesEvicted here?resume at the same step
The commit is a separate write on purpose — it is the only durable record of progress.
 1// migrations.js
 2import { STEPS } from "./steps.js";
 3import { readSchemaVersion } from "./schema.js";
 4
 5export async function runMigrations() {
 6  const from = await readSchemaVersion();
 7  const pending = STEPS.filter((s) => s.to > from);
 8  if (!pending.length) return { from, to: from, ran: [] };
 9
10  const ran = [];
11  for (const step of pending) {
12    const started = Date.now();
13    await step.run();
14    await chrome.storage.local.set({ schemaVersion: step.to });
15    ran.push({ name: step.name, ms: Date.now() - started });
16  }
17
18  await chrome.storage.local.set({ lastMigration: { at: Date.now(), from, ran } });
19  return { from, to: pending.at(-1).to, ran };
20}

Execution context: Service worker, called from onInstalled and from script evaluation on every cold start. Recording lastMigration costs one write and repays it the first time a user reports data loss — you can ask them to paste it from the storage inspector. In Chrome and Firefox the loop typically completes in well under a second for a settings-sized store; Safari’s storage calls carry more per-call overhead, so batch reads rather than calling get per key inside a step.

Step 4. Make every reader wait

A migration that runs correctly and a reader that runs during it are still a bug together.

 1// sw.js — top level
 2import { runMigrations } from "./migrations.js";
 3
 4export const ready = runMigrations().then(
 5  (result) => { if (result.ran.length) console.info("migrated", result); },
 6  (error) => { console.error("migration failed", error); throw error; },
 7);
 8
 9chrome.runtime.onInstalled.addListener(() => { /* ready is already running */ });
10
11chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
12  ready
13    .then(() => handleMessage(msg))
14    .then(sendResponse, (error) => sendResponse({ ok: false, error: String(error) }));
15  return true;
16});

Execution context: Service worker. Starting the promise during script evaluation rather than inside the onInstalled handler is deliberate — onInstalled does not fire on ordinary cold starts, so a migration wired only to it would never run for a user whose browser applied the update while the extension was idle and then restarted. Returning true from the message listener keeps the reply channel open, without which the popup receives undefined; the mechanics are covered in fixing message port closed before response errors.

Step 5. Fail loudly, and never destructively

If a step throws, stop. Do not skip ahead, do not clear the area, and do not let the extension operate on data it does not understand.

Failure modes and what the runner does with eachTransient storage errors, a genuinely broken transform and a missing upgrade path each need a different response from the migration runner.FailureRunner responseStore left atUser seesTransient storage errorRetry next startLast good stepNothingWorker evicted mid-stepResume same stepLast good stepNothingTransform throwsStop, badgeLast good stepWarning badgeNo path from this versionStop, badgeUntouchedWarning badge
Only the last row is unrecoverable, and even it leaves the store at a known-good schema version.
1// sw.js
2ready.catch(async () => {
3  await chrome.action.setBadgeText({ text: "!" });
4  await chrome.action.setBadgeBackgroundColor({ color: "#dc2626" });
5  await chrome.action.setTitle({ title: "Update problem — click for details" });
6  await chrome.storage.local.set({ migrationBlocked: true });
7});

Execution context: Service worker. Because the runner commits after each successful step, a failure leaves the store at the last known-good schema version rather than somewhere in between — the next start retries the failing step, which is the right behaviour when the cause was a transient storage error. Surfacing it on the toolbar icon means the user has something to report instead of an extension that quietly does nothing. Badge text and colour behave identically in Chrome and Firefox; Safari renders the badge smaller and truncates aggressively, so keep it to a single character.

Cross-browser variation

  • Chrome/Edge: onInstalled fires with reason: "update" and a populated previousVersion. storage.local has a 10 MB quota by default and no per-write throttle, so a migration that rewrites everything is fine.
  • Firefox: identical event semantics. browser.storage.local is backed by IndexedDB with a larger effective quota, but the same per-step commit discipline applies because the worker is still evictable.
  • Safari: onInstalled fires when the containing app delivers the update. Storage calls have higher latency, so prefer one get(null) and one set per step over many small calls.
  • All engines: there is no onUninstalled for your own extension, so cleanup on removal is not something you can schedule — design migrations so that leftover keys are harmless.

Verification

  1. In the worker console, force a replay: await chrome.storage.local.set({ schemaVersion: 0 }), reload the extension, and confirm every step logs and the data ends in the same state as before.
  2. Run the same replay twice in a row. The second run should log the same end state, proving idempotence.
  3. Simulate an eviction by reloading the extension while a deliberately slowed step is running, then reload again. The interrupted step should re-run and complete.
  4. Check chrome.storage.local.get("lastMigration") after an update and confirm the step names and timings look sane.
  5. Make one step throw on purpose. The badge should show !, schemaVersion should remain at the previous step’s value, and no data should be missing.

FAQ

Should migrations run in onInstalled or at script evaluation?

Both, via the same promise. onInstalled catches the update itself; script evaluation catches the cold start of a user who was offline when the update landed.

What if a migration needs to call the network?

Keep it out of the runner. Migrate the local shape synchronously and mark the record as needing a refetch, so the extension is usable offline and the network work happens on the normal code path.

How do I test against a real old profile?

Keep a fixture per historical schema version — a JSON blob you can load with chrome.storage.local.set — and replay the runner over each one in your test suite. Mocking Chrome APIs in Jest covers the storage double that makes this cheap.

Can I delete old steps once everyone has upgraded?

Only if you also raise the floor: a user restoring a three-year-old profile backup still starts at their old schema version. Removing steps means those users skip straight to current with unmigrated data.

Other MV3 Architecture & Extension Lifecycle Resources