Defaulting and Versioning an Options Schema

Keep extension settings readable across every version you ship — defaults merged at read time, a single parser for stored values, numbered migrations, and tolerating data from the future.

Published September 18, 2026 Updated September 18, 2026 8 min read
Table of Contents

Settings outlive the code that wrote them. A value stored by version 1.2 will be read by 1.3, 2.0 and 3.1, on devices that skipped versions, after imports from files, and occasionally by a downgraded build. The extensions that handle this well share one idea: storage holds only what the user changed, code owns every default, and exactly one function turns whatever is on disk into a settings object the rest of the code can trust. This guide is part of options page configuration.

Why “write the defaults on install” goes wrong

The tempting pattern is to write a complete settings object in onInstalled. It makes the first read trivial and every later change hard: a new setting added in version 2.0 is absent from every existing user’s stored object, a changed default never reaches anyone who installed before it changed, and on a second device the write races the incoming sync and can overwrite it.

Stored defaults against code-owned defaultsTwo approaches compared on new settings, changed defaults, second-device installs, storage size and the cost of a read.SituationWrite defaults on installMerge defaults on readNew setting addedMissing for existing usersAppears automaticallyDefault value changedNever reaches old installsApplies unless user choseInstall on a second deviceMay overwrite synced valuesNothing writtenStorage usedEvery settingOnly user choicesCost per readNoneOne object spread
Merging defaults at read time costs one spread per read and removes three whole categories of bug.

Step-by-step

1. Keep defaults in one frozen object

1// settings/schema.js
2export const SCHEMA = 4;
3
4export const DEFAULTS = Object.freeze({
5  theme: "auto",                 // "light" | "dark" | "auto"
6  syncHour: 7,                   // 0–23
7  enabledOrigins: [],            // https origins
8  badge: "count",                // "count" | "dot" | "none"   (added in schema 4)
9});

Execution context: a shared module imported by the service worker and every extension page. Object.freeze stops a caller mutating the defaults by accident — a surprisingly common bug when a component pushes into settings.enabledOrigins and silently edits the shared array.

2. Write one parser for every read

Everything that reads settings — the worker, the popup, the options page, an import — goes through the same function. It accepts anything and returns a valid object.

 1const THEMES = new Set(["light", "dark", "auto"]);
 2const BADGES = new Set(["count", "dot", "none"]);
 3
 4export function parseSettings(raw) {
 5  const o = raw && typeof raw === "object" ? raw : {};
 6  return {
 7    theme: THEMES.has(o.theme) ? o.theme : DEFAULTS.theme,
 8    syncHour: Number.isInteger(o.syncHour) && o.syncHour >= 0 && o.syncHour < 24 ? o.syncHour : DEFAULTS.syncHour,
 9    enabledOrigins: Array.isArray(o.enabledOrigins)
10      ? o.enabledOrigins.filter((s) => typeof s === "string" && /^https?:\/\//.test(s)).slice(0, 500)
11      : [...DEFAULTS.enabledOrigins],
12    badge: BADGES.has(o.badge) ? o.badge : DEFAULTS.badge,
13  };
14}

Execution context: a shared module. Each field is validated independently, so one corrupt value falls back to its default without discarding the rest — the property that makes the parser safe to run on imported files as well as on storage, as in exporting and importing extension settings.

3. Store only what the user changed

1export async function setSetting(key, value) {
2  const { settings: stored = {} } = await chrome.storage.sync.get("settings");
3  const next = { ...stored, [key]: value };
4  if (JSON.stringify(value) === JSON.stringify(DEFAULTS[key])) delete next[key];
5  await chrome.storage.sync.set({ settings: next });
6}

Execution context: any extension page. Removing a key when the user sets it back to the default means a future change of that default reaches them — they never expressed a preference, so they should get the new behaviour. It also keeps the synced object small against the 8 KB per-item cap.

4. Number your migrations

When a stored shape genuinely changes — a renamed key, a value that moves from a boolean to an enum — the parser alone is not enough, because it cannot know what the old key meant. Migrations run first, then the parser.

 1// settings/migrate.js
 2const STEPS = {
 3  2: (s) => { if ("darkMode" in s) { s.theme = s.darkMode ? "dark" : "light"; delete s.darkMode; } return s; },
 4  3: (s) => { if (typeof s.syncHour === "string") s.syncHour = parseInt(s.syncHour, 10); return s; },
 5  4: (s) => { if (s.showBadge === false) s.badge = "none"; delete s.showBadge; return s; },
 6};
 7
 8export function migrateSettings(stored, fromSchema) {
 9  let s = { ...stored };
10  for (let v = fromSchema + 1; v <= SCHEMA; v++) s = STEPS[v]?.(s) ?? s;
11  return s;
12}

Execution context: a shared module, run by the service worker on onInstalled with reason: "update" and by the import path. Each step only knows about one transition, so skipping versions — the normal case for a user who was offline for a month — runs every intermediate step in order. The update-time driver is in running data migrations on onInstalled.

5. Tolerate data from the future

A user who downgrades, or who has a newer version on another synced device, will hand an older build a schema it does not know. The older build must not destroy the data.

1export async function readSettings() {
2  const { settings: stored = {}, settingsSchema = 1 } = await chrome.storage.sync.get(["settings", "settingsSchema"]);
3  if (settingsSchema > SCHEMA) {
4    return { ...parseSettings(stored), __readOnly: true };   // read what we understand, write nothing
5  }
6  return parseSettings(migrateSettings(stored, settingsSchema));
7}

Execution context: any context that reads settings. The __readOnly flag lets the options page disable saving and explain why, rather than writing an old-shaped object over a newer device’s settings.

Where defaults and migrations fail in practice

Most schema bugs are not in the migration logic; they are in assumptions around it.

Mutable defaults. settings.enabledOrigins.push(origin) on an object that came from { ...DEFAULTS } mutates the shared default array, because the spread is shallow. Every later read then sees the pushed value as a “default”. Freezing DEFAULTS turns this into a thrown error in development instead of a subtle cross-user bug.

Migrations with side effects. A step that requests a permission, opens a tab or makes a network call cannot be safely re-run — and interrupted updates do re-run migrations. Keep steps pure transformations of an object; do the side effects afterwards, once, based on the result.

Schema bumps without a step. Incrementing SCHEMA for a change that needs no transformation is fine, but leaving a gap in the step table and forgetting it later is how a step for version 6 gets written as version 5’s. A test that asserts every version from 2 to SCHEMA has an entry, even if it is the identity, closes the gap.

1for (let v = 2; v <= SCHEMA; v++) assert.ok(v in STEPS, `missing migration step ${v}`);

Execution context: a Node test alongside the module. Paired with fixtures from each released version, as described in testing an update before you publish it, it makes schema changes one of the safest things to ship.

Every read, from disk to a trusted objectA stored object and its schema number are read, compared against the current schema, migrated step by step if older, parsed field by field, and merged over frozen defaults.storage.sync.getsettings + schemaschema > current?downgrade caseRead-onlyparse, never writeotherwise migrate forwardSTEPS[n+1 … current]pure transformsparseSettingsfield by fieldTrusted settingsdefaults filled in
Newer data takes the read-only branch — the one path that must never write.
One user's settings across four releasesA settings object written under schema 1, carried through three migrations as the user updates, with a new setting appearing by default in schema 4.v1.2 installv3.1schema 1darkMode: trueschema 2theme: "dark"schema 3syncHour as a numberschema 4badge from defaultkey renamednew setting, no write
The user never touched the badge setting, so the new default simply applies — nothing was written for it.

Cross-browser variation

  • Chrome / Edge: storage.sync carries the settings and schema between devices, so a downgrade on one device can meet newer data from another — the read-only branch is a real path, not a theoretical one.
  • Firefox: identical storage semantics under browser.storage. Firefox users more often run older extension versions for longer, which makes long migration chains more common.
  • Safari: slower sync convergence means a newer schema can appear on a device minutes after the older build started. Re-read on storage.onChanged rather than caching the schema number at startup.
  • All three: Object.freeze and shallow spreads behave identically; the mutable-defaults bug is engine-independent.

Verification

  1. Write a schema-1 object directly and confirm the read path migrates it:
1await chrome.storage.sync.set({ settings: { darkMode: true, syncHour: "9" }, settingsSchema: 1 });
2await readSettings();
3// { theme: "dark", syncHour: 9, enabledOrigins: [], badge: "count" }

Execution context: the options page console. Every field should be present and correctly typed, including ones the stored object never had.

  1. Set settingsSchema to SCHEMA + 1 and confirm the options page disables saving and explains why.
  2. Set a value back to its default and confirm the key disappears from storage.
  3. Run the step-table and fixture tests and confirm they pass.

FAQ

Should the schema number live inside the settings object?

Either works. A separate key is slightly more robust because a corrupt settings object does not also lose the number that says how to read it.

Do I need a migration for a new setting?

No — the defaults cover it. Migrations are for changes in the meaning or shape of values that are already stored.

What about settings stored in storage.local?

Same pattern, separate schema number. Device-level settings evolve independently of synced ones, and mixing their version numbers makes both harder to reason about.

Other MV3 Architecture & Extension Lifecycle Resources