Browser extensions store data in chrome.storage.local and chrome.storage.sync. Unlike a server-side database, you do not control when that storage is updated — each user’s extension updates at a different time, from a different version, based on their Chrome update schedule. A user who installed your extension six months ago may have storage data shaped completely differently from a user who installed it yesterday.
If your code assumes a storage shape that does not match what a user actually has, you get silent failures, broken features, and data loss. This is the storage migration problem, and it is one of the most neglected areas of extension development.
Why This Is Harder Than a Database Migration
In a web application, you run a database migration script once, at deploy time. Every user is on the same schema immediately after the deployment. You control the moment the migration happens.
In a browser extension, you have no such control. When you publish version 2.3 of your extension, Chrome updates users gradually over days or weeks — in the background, without user action. When a user’s extension updates from 2.1 to 2.3 in the background, their chrome.storage data is still in the 2.1 shape. Your 2.3 code runs the next time they open their browser, expecting the 2.3 shape, and finds the 2.1 shape instead.
There are several ways this breaks:
- A field you renamed still has the old name in storage: your code reads
undefinedand proceeds with a bad default - A field you moved from
chrome.storage.synctochrome.storage.localexists in sync only: your code cannot find it - An array you added does not exist in old storage: your code calls
.map()onundefined - A string field you changed to an object: your code tries to access
.propertyon a string
The Version-Tracked Migration Pattern
The solution is to run a migration function on every service worker startup, check the current storage schema version, and apply any pending migrations in order.
// In your service worker (background.ts)
import { runStorageMigrations } from '@repo/core'
chrome.runtime.onInstalled.addListener(runStorageMigrations)
chrome.runtime.onStartup.addListener(runStorageMigrations)
The migration runner reads the current schema version from storage, applies every migration with a higher version number, and writes the new version back. Each migration is a pure function that takes storage data and returns updated storage data:
type Migration = {
version: number
up: (data: Record<string, unknown>) => Record<string, unknown>
}
const migrations: Migration[] = [
{
version: 1,
up: (data) => {
// Initial schema — nothing to migrate from
return { ...data, schemaVersion: 1 }
},
},
{
version: 2,
up: (data) => {
// v1 stored theme as a string: 'light' | 'dark'
// v2 stores it as an object: { mode: 'light' | 'dark', accent: string }
const legacyTheme = data.theme as string | undefined
return {
...data,
theme: {
mode: legacyTheme ?? 'light',
accent: '#6366f1',
},
schemaVersion: 2,
}
},
},
{
version: 3,
up: (data) => {
// v2 stored dismissed notices as a boolean
// v3 uses a set (stored as array) to track which notices were dismissed
return {
...data,
dismissedNotices: data.dismissedWelcome ? ['welcome'] : [],
dismissedWelcome: undefined, // remove old key
schemaVersion: 3,
}
},
},
]
The runner applies only the migrations above the current version:
export async function runStorageMigrations() {
const stored = await chrome.storage.local.get('schemaVersion')
const currentVersion = (stored.schemaVersion as number) ?? 0
const pending = migrations.filter((m) => m.version > currentVersion)
if (pending.length === 0) return
let data = await chrome.storage.local.get(null)
for (const migration of pending) {
data = migration.up(data)
}
await chrome.storage.local.set(data)
}
A user who updates from v1 to v3 will have migrations 2 and 3 applied in sequence. A new user who installs v3 fresh will have migration 1 applied (or start from migration 1’s baseline). A user already on v3 will have no migrations applied — the function returns early.
What to Migrate, What Not To
Migrate:
- Renamed fields
- Restructured data (string → object, flat → nested)
- Moved data between storage areas (sync ↔ local)
- Removed fields that code no longer reads (set them to
undefinedso the key is deleted) - Type coercions (string numbers → actual numbers)
Do not migrate:
- Data you can reconstruct — if a field can be recomputed from other storage data, just recompute it on read
- User-generated content — if you must migrate user data, build in a backup or dry-run step
Handling chrome.storage.sync
If you use chrome.storage.sync for settings that should sync across devices, the same migration pattern applies — you need a separate schema version key for sync storage and a separate set of sync migrations.
One practical approach: store user preferences in chrome.storage.sync and application state in chrome.storage.local, with separate schema version keys for each. Keep sync storage small (Chrome limits it to 100KB total, 8KB per key) and migrate it independently.
Testing Migrations
The migration functions are pure: given input data, they return output data. This makes them easy to unit test without any browser API mocking:
describe('migration v2', () => {
it('converts string theme to object', () => {
const input = { theme: 'dark', schemaVersion: 1 }
const output = migrations.find(m => m.version === 2)!.up(input)
expect(output.theme).toEqual({ mode: 'dark', accent: '#6366f1' })
})
it('uses light as default when theme is missing', () => {
const input = { schemaVersion: 1 }
const output = migrations.find(m => m.version === 2)!.up(input)
expect(output.theme).toEqual({ mode: 'light', accent: '#6366f1' })
})
})
Write a test for every migration when you write the migration. Old migration tests should never be deleted — they document the historical schema and verify that future migrations cannot accidentally break the transformation chain.
What LightningAddon Ships
LightningAddon ships runStorageMigrations() in @repo/core, called from the background service worker on both onInstalled and onStartup. The migration array starts with the baseline schema. When you add a new version, you add one migration object to the array — the runner handles the rest.
The pattern is simple enough that you could implement it yourself in an afternoon. What it saves you is not implementation time — it is the hours spent debugging “works for me, breaks for old users” reports six months after launch.
Jenny Wilson