Migrating Storage Schema Between Versions
Version and migrate chrome.storage data across extension updates: a schemaVersion key, forward-only migration steps in onInstalled, and recovering from a half-applied migration.
Table of Contents
An extension update ships a new data shape and a fraction of users immediately see an empty UI, a crash on read, or settings reset to defaults — because their stored data is still in the old shape and nothing translated it. Extensions have no schema migration framework: the only hook is chrome.runtime.onInstalled with reason === "update", and it fires exactly once per update on a service worker that may be evicted mid-migration. This page is part of Chrome Storage API & Sync and covers a migration path that is safe to interrupt.
Root cause: the update boundary is a single, interruptible event
When the browser applies an update, it discards the old worker, installs the new code and fires onInstalled with the previous version string. That handler is your only migration window, and it is subject to the same eviction rules as any other handler. If the migration is one long function that rewrites several keys, an eviction halfway through leaves storage in a shape that neither the old nor the new code expects — and onInstalled will not fire again to finish the job.
Step 1. Store a schema version alongside the data
The extension version is the wrong key to migrate against — it changes for every release, including releases that do not touch the data shape. Keep a separate integer that increments only when the shape changes.
1// schema.js
2export const SCHEMA_VERSION = 4;
3
4export async function readSchemaVersion() {
5 const { schemaVersion } = await chrome.storage.local.get("schemaVersion");
6 // A fresh install has no key. Treat it as current: there is nothing to migrate.
7 if (typeof schemaVersion !== "number") return null;
8 return schemaVersion;
9}
Execution context: Shared module imported by the service worker. Keeping schemaVersion in storage.local rather than storage.sync is deliberate — a synced version number can arrive from a device running a newer build and make an older build think it has already migrated. Return null for a fresh install so the caller can seed defaults instead of running migrations against an empty object.
Step 2. Write forward-only, idempotent steps
Each step takes storage from version N to N+1, and each must be safe to run twice. Idempotence is what makes an interrupted migration recoverable: re-running the sequence from the recorded version cannot corrupt data that a partially applied step already rewrote.
1// migrations.js — index N migrates version N to N + 1
2const MIGRATIONS = [
3 // 0 -> 1: single "sites" string became an array.
4 async () => {
5 const { sites } = await chrome.storage.local.get("sites");
6 if (typeof sites === "string") {
7 await chrome.storage.local.set({ sites: sites.split(",").map((s) => s.trim()).filter(Boolean) });
8 }
9 },
10 // 1 -> 2: interval moved from seconds to minutes and gained a floor.
11 async () => {
12 const { intervalSeconds } = await chrome.storage.local.get("intervalSeconds");
13 if (typeof intervalSeconds === "number") {
14 await chrome.storage.local.set({ pollMinutes: Math.max(1, Math.round(intervalSeconds / 60)) });
15 await chrome.storage.local.remove("intervalSeconds");
16 }
17 },
18 // 2 -> 3: per-site options became an object map keyed by host.
19 async () => {
20 const { sites = [] } = await chrome.storage.local.get("sites");
21 const { siteOptions } = await chrome.storage.local.get("siteOptions");
22 if (!siteOptions) {
23 await chrome.storage.local.set({
24 siteOptions: Object.fromEntries(sites.map((host) => [host, { enabled: true }])),
25 });
26 }
27 },
28 // 3 -> 4: secrets must not live in sync storage any more.
29 async () => {
30 const { token } = await chrome.storage.sync.get("token");
31 if (token) {
32 await chrome.storage.local.set({ token });
33 await chrome.storage.sync.remove("token");
34 }
35 },
36];
37
38export async function migrate(from) {
39 for (let v = from; v < MIGRATIONS.length; v += 1) {
40 await MIGRATIONS[v]();
41 // Record progress after EACH step, so an eviction resumes at the right place.
42 await chrome.storage.local.set({ schemaVersion: v + 1 });
43 }
44}
Execution context: Extension service worker. Every step guards on the shape it expects to find, so re-running a completed step is a no-op rather than a corruption. Recording schemaVersion after each step — not once at the end — is what turns an interrupted migration into a resumable one. Step 3 is a real-world case worth copying: moving a secret out of sync storage, as recommended in encrypting sensitive data in chrome storage.
Step 3. Gate every read behind a migration check
Because a migration can be interrupted, onInstalled cannot be the only trigger. Put a cheap guard in front of the code paths that read data, so the first ordinary wake after an interruption completes the job.
1// background.js
2import { SCHEMA_VERSION, readSchemaVersion } from "./schema.js";
3import { migrate } from "./migrations.js";
4
5let migrated = null; // per-wake memo; deliberately discarded on eviction
6
7export function ensureMigrated() {
8 migrated ??= (async () => {
9 const from = await readSchemaVersion();
10 if (from === null) {
11 await seedDefaults();
12 await chrome.storage.local.set({ schemaVersion: SCHEMA_VERSION });
13 return;
14 }
15 if (from < SCHEMA_VERSION) await migrate(from);
16 })();
17 return migrated;
18}
19
20chrome.runtime.onInstalled.addListener(() => { void ensureMigrated(); });
21chrome.runtime.onStartup.addListener(() => { void ensureMigrated(); });
22
23chrome.alarms.onAlarm.addListener((alarm) => {
24 (async () => {
25 await ensureMigrated(); // every entry point awaits the same promise
26 await handleAlarm(alarm);
27 })();
28});
Execution context: Extension service worker, all listeners registered at the top level. The migrated memo collapses concurrent callers within one wake into a single migration run, and losing it on eviction is correct — the next wake re-reads the recorded version, which is authoritative. A version newer than SCHEMA_VERSION means the user downgraded or a synced value arrived from a newer build; the code above leaves the data alone rather than attempting a backward migration, which is the safe choice.
Step 4. Avoid the four migrations that lose data
Most migration bugs are one of a small set, and all four are avoidable by construction rather than by care.
The second row is worth dwelling on. chrome.storage.local.clear() followed by writing the new shape is tempting because it guarantees a clean state, and it is the migration most likely to generate support mail — an eviction between the two calls leaves the user with nothing at all. Read the old value, compute the new one, write it, and only then remove the obsolete key: in that order, every interruption is recoverable.
Cross-browser variation
- Chrome 120+:
onInstalledprovidesdetails.previousVersionfor updates. The worker can be evicted during the handler, which is the case the resume path exists for. - Firefox 121+: same event and payload. The background context is an event page and is less eager to terminate, so an interrupted migration is rarer — do not let that hide a missing resume path.
- Safari 17+: fires
onInstalledon update, but app-level updates through the App Store can also present as a fresh install. Anullschema version must therefore seed defaults without destroying existing keys, so seed withseton missing keys rather than overwriting the whole object. - All engines:
storage.sessionis empty after every browser restart, so never migrate into it — migrations should only ever touchlocalandsync.
Verification
- Load the previous version unpacked, use it enough to create real data, then load the new version over it. The UI should show migrated data, not defaults.
- Log
schemaVersionbefore and after. It must equalSCHEMA_VERSIONwhen the migration completes. - Simulate an interruption: temporarily throw inside step 3, load the update, confirm
schemaVersionstops at 2, remove the throw, then fire an alarm and confirm the sequence resumes and completes. - Run the update twice by reloading the unpacked extension. No step should change anything the second time.
FAQ
Why not migrate against the extension version number?
Because most releases do not change the data shape. Migrating on every version means writing a no-op step for each release, and a missed step becomes a silent gap. An integer that only moves when the shape moves has no gaps.
What happens if a user skips several versions?
Nothing special — the loop runs every step from their recorded version to the current one. That is the whole reason steps are forward-only and self-guarding rather than a single transform from “old” to “new”.
Should migrations touch storage.sync?
Only to move data out of it, as in step 4. A sync migration races with replication from other devices, so treat sync as read-mostly during migration and keep the authoritative shape in local.
How do I handle a version newer than the code expects?
Leave the data untouched and degrade the feature rather than attempting a downgrade. Writing an older shape over newer data loses information that the newer build on another device still needs.
Related
- chrome.storage.onChanged listener patterns — reacting once the shape is settled.
- chrome.storage.session vs local — which area survives what.
- Encrypting sensitive data in chrome storage — the destination for step 4.
- Chrome Storage API & Sync — the guide this page belongs to.