Migrating Options from localStorage

Move settings stored in localStorage by an MV2 background or options page into chrome.storage during an MV3 update: reading legacy keys with an offscreen document, type conversion, one-time migration and cleanup.

Published October 2, 2026 Updated October 2, 2026 7 min read
Table of Contents

The MV2 version saved every setting with localStorage.setItem("theme", "dark") from its options page and read them synchronously in its background page. The MV3 version reads settings from chrome.storage.sync, and after the update every user’s preferences are back to defaults — because localStorage is not available in the service worker, nobody migrated the old values, and the new code never looks for them. The data is still there, on the extension origin, waiting to be read by any extension page. This guide moves it across once, safely, and cleans up afterwards. It belongs to options page configuration.

Where the old settings are

localStorage is per origin. In MV2, the background page, the options page and the popup all ran on chrome-extension://<id>/, so whatever any of them stored lives in one localStorage for that origin. An update keeps the same extension id and origin, so the data survives the update untouched. What changes in MV3 is who can read it: the service worker has no localStorage, but any extension page — options, popup, side panel, or an offscreen document — still does. The migration is therefore a small job: open a page on the extension origin, read the legacy keys, convert their types (everything in localStorage is a string), write them to chrome.storage, and mark the migration done.

Old and new settings storage on the same originLegacy localStorage written by MV2 pages and the new chrome.storage areas both live on the extension origin; extension pages can read localStorage, while the service worker can only read chrome.storage.chrome.storage.syncnew home for settingsworker + pageschrome.storage.localmigration flags, cachesworker + pageslocalStorage (legacy)strings from MV2pages onlyExtension originchrome-extension://<id>unchanged by update
The data never left — only the service worker lost the ability to read it.

Step-by-step: a one-time migration

1. Inventory the legacy keys and their types

1// shared/legacy-keys.js
2export const LEGACY = {
3  theme:          { to: "theme",          parse: (v) => (v === "dark" || v === "light" ? v : "system") },
4  highlight:      { to: "highlightPrices", parse: (v) => v === "true" },
5  fontSize:       { to: "fontScale",      parse: (v) => Math.min(3, Math.max(0.5, Number(v) / 16 || 1)) },
6  blockedSites:   { to: "disabledSites",  parse: (v) => { try { return JSON.parse(v).filter((s) => typeof s === "string"); } catch { return []; } } },
7};

Execution context: a shared module. Every localStorage value is a string, so booleans arrive as "true"/"false", numbers as text, and objects as JSON that may be malformed after years of buggy writes. Each parser converts defensively and returns a valid value for the new schema, including renamed keys and changed units (pixel font sizes becoming a scale factor here).

2. Run the migration from an extension page

 1// options.js (also safe to run from the popup or an offscreen document)
 2import { LEGACY } from "./shared/legacy-keys.js";
 3
 4export async function migrateLegacyLocalStorage() {
 5  const { legacyMigrated } = await chrome.storage.local.get("legacyMigrated");
 6  if (legacyMigrated) return false;
 7  const patch = {};
 8  for (const [oldKey, { to, parse }] of Object.entries(LEGACY)) {
 9    const raw = localStorage.getItem(oldKey);
10    if (raw !== null) patch[to] = parse(raw);
11  }
12  const existing = await chrome.storage.sync.get(Object.keys(patch));
13  const toWrite = Object.fromEntries(Object.entries(patch).filter(([k]) => !(k in existing)));   // never overwrite newer values
14  await chrome.storage.sync.set(toWrite);
15  await chrome.storage.local.set({ legacyMigrated: { at: Date.now(), keys: Object.keys(toWrite) } });
16  return true;
17}

Execution context: an extension page, which has localStorage for the extension origin. Skipping keys already present in chrome.storage.sync matters for users with several devices: a device that migrated first may already have synced values that are newer than this device’s legacy copy. The legacyMigrated flag makes the migration run once per profile.

Migrating on update via an offscreen documentAfter the update, onInstalled creates an offscreen document with the LOCAL_STORAGE reason; the document reads legacy keys, converts types and writes them to chrome.storage.sync unless newer values exist; it records the migration and closes.Service workerOffscreen docchrome.storagecreateDocument(LOCAL_STORAGE)read localStorage keyssync.get(existing)sync.set(missing only)local.set({legacyMigrated})closeDocument()
The worker can't read localStorage, so it borrows a page that can.

3. Trigger it on update from the worker

 1// sw.js
 2chrome.runtime.onInstalled.addListener(async ({ reason, previousVersion }) => {
 3  if (reason !== "update" || !previousVersion?.startsWith("2.")) return;    // last MV2 line was 2.x
 4  const { legacyMigrated } = await chrome.storage.local.get("legacyMigrated");
 5  if (legacyMigrated) return;
 6  await chrome.offscreen.createDocument({
 7    url: "migrate.html", reasons: ["LOCAL_STORAGE"], justification: "Migrate settings saved by the previous version",
 8  });
 9});
10
11chrome.runtime.onMessage.addListener((m) => {
12  if (m?.type === "migration:done") chrome.offscreen.closeDocument().catch(() => {});
13});

Execution context: the service worker. Running the migration immediately after the update means settings are correct before the user opens anything. The LOCAL_STORAGE offscreen reason exists for exactly this. migrate.html loads a script that calls migrateLegacyLocalStorage() and then messages migration:done. As a fallback, also call the migration when the options page or popup opens — cheap, because of the flag.

Legacy values and how to convert themTypical localStorage string values from MV2 extensions and the conversion each needs before writing to chrome.storage.Legacy valueStored asConvert withBoolean"true" / "false"v === "true"Number"16"Number(v) + clampObject / arrayJSON texttry JSON.parse + validateMissing keynullSkip; default appliesCorrupt JSONUnparseableFallback value
Everything in localStorage is a string — every value needs a parser.

4. Clean up legacy data later, not immediately

1// In a release several versions later
2if ((await chrome.storage.local.get("legacyMigrated")).legacyMigrated) {
3  for (const k of Object.keys(LEGACY)) localStorage.removeItem(k);
4}

Execution context: an extension page in a later release. Keeping legacy keys for a while costs almost nothing and preserves a recovery path if the migration code had a bug: a fixed migration can re-run against the original data by clearing the flag. Remove them once telemetry shows the migration succeeded broadly.

5. Replace synchronous reads in the code

1// MV2:  const theme = localStorage.getItem("theme") || "light";   ← synchronous
2// MV3:
3const { theme = "system" } = await chrome.storage.sync.get("theme");

Execution context: every former localStorage reader. chrome.storage is asynchronous, so call sites must become async. Popups that rendered synchronously from localStorage need a loading state or a skeleton to avoid flashing defaults — see loading popup data without a flash of empty UI.

6. Keep using localStorage only where it truly fits

localStorage in extension pages still works and is synchronous, which is tempting for UI conveniences such as “last selected tab in the options page”. It is not shared with the worker, does not sync, and is cleared differently from chrome.storage. Keep it, if at all, for page-local ephemera, never for settings that the worker or content scripts need.

7. Verify on a real upgrade path

Install the last MV2 release in a fresh profile, set several options, then update to the MV3 build as the same extension id. The options page should show the previous values, and the worker should behave according to them, without the user doing anything.

Common mistakes

  • Expecting the worker to read localStorage. It cannot; use a page.
  • Copying strings without conversion. "false" is truthy.
  • Overwriting synced values from another device. Write only missing keys.
  • Deleting legacy data in the same release. No way to fix a buggy migration.
  • No run-once flag. The migration re-runs and clobbers new changes.

Cross-browser variation

  • Chrome / Edge: offscreen document with LOCAL_STORAGE reason, or run from any extension page.
  • Firefox: the MV3 event-page background has localStorage, so the migration can run directly in onInstalled.
  • Safari: extension pages have localStorage; run the migration when the popup or options page first opens after the update.

Verification

  1. In a profile with MV2-era localStorage values, update to MV3 and confirm chrome.storage.sync contains converted values.
  2. Confirm legacyMigrated is set and the migration does not run again on reload.
  3. Pre-seed chrome.storage.sync with a newer value and confirm the migration does not overwrite it.
  4. Corrupt a legacy JSON value and confirm the migration falls back without failing.

FAQ

Can I read localStorage from a content script?

No — content scripts see the page’s localStorage, not the extension’s.

What about IndexedDB data from MV2?

It also stays on the extension origin and is readable from the service worker in MV3, so it usually needs no migration — only schema updates.

Should settings go to sync or local?

User preferences that should follow the user go to sync; device-specific or large data to local.

What if the user never opens the extension after updating?

The offscreen-document path runs from onInstalled, so the migration completes without any user action. The page-open fallback only matters if that step failed.

How do I know the migration worked across my user base?

Record a consented, aggregate count of migrations completed and keys migrated (names, never values). A sudden drop compared with update counts points to a migration bug worth investigating before you remove the legacy data.

Other MV3 Architecture & Extension Lifecycle Resources