How to Update Your Extension Without Breaking Existing Users

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 upda

  • Jenny Wilson Jenny Wilson
  • date icon

    Saturday, Mar 14, 2026

How to Update Your Extension Without Breaking Existing Users

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 undefined and proceeds with a bad default
  • A field you moved from chrome.storage.sync to chrome.storage.local exists in sync only: your code cannot find it
  • An array you added does not exist in old storage: your code calls .map() on undefined
  • A string field you changed to an object: your code tries to access .property on 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 undefined so 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.

Blog

Read More Posts

Your Trusted Partner in Data Protection with Cutting-Edge Solutions for
Comprehensive Data Security.

How to Get Your First 100 Paying Users for a Browser Extension
date icon

Tuesday, Mar 17, 2026

How to Get Your First 100 Paying Users for a Browser Extension

Most browser extension SaaS products never reach 100 paying users — not because they are bad products, but because the d

Read More
Supabase vs Firebase for Your Extension Backend: A Practical 2026 Comparison
date icon

Sunday, Mar 15, 2026

Supabase vs Firebase for Your Extension Backend: A Practical 2026 Comparison

If you are starting a browser extension SaaS, you need a backend for auth, data storage, and Stripe webhook handling. Su

Read More
How to Update Your Extension Without Breaking Existing Users
date icon

Saturday, Mar 14, 2026

How to Update Your Extension Without Breaking Existing Users

Browser extensions store data in chrome.storage.local and chrome.storage.sync. Unlike a server-side database, you do

Read More
LightningAddon Chrome, Firefox & Safari extension framework call to action

Stop Rebuilding the Same Foundation. Start Shipping.

Auth, billing, multi-browser builds, typed messaging — every extension SaaS needs the same boring foundation. LightningAddon ships all of it, battle-tested and ready in 15 minutes. Pay once, own it forever.

Get LightningAddon