Reacting to Option Changes in Every Context
Apply MV3 extension settings everywhere the moment they change: storage.onChanged in the worker, popup, side panel and content scripts, typed settings with defaults, avoiding stale caches, and re-registering scripts.
Table of Contents
- How a change propagates
- Step-by-step: settings that apply everywhere
- Common mistakes
- Cross-browser variation
- Verification
- FAQ
- Do I need to listen in both
onChangedand the area-specificchrome.storage.sync.onChanged? - Will content scripts in background tabs receive changes?
- Should the options page save on every keystroke?
- How do I keep the options page itself in sync when another device changes a setting?
- Is
chrome.storage.localchange propagation the same?
- Do I need to listen in both
- Related
The user switches “Highlight prices” off in the options page. The popup, still open in another window, shows it as on. Content scripts in a dozen open tabs keep highlighting prices until each tab is reloaded. The service worker keeps the old value in a variable it read at startup. Settings written by the options page are stored correctly — the problem is that every other context read them once and never looked again. chrome.storage.onChanged delivers each change to every context that can read the storage area, and wiring it up properly makes settings feel instant everywhere. This guide does that. It belongs to options page configuration.
How a change propagates
When any context writes to chrome.storage.sync or local, the browser fires chrome.storage.onChanged in every other extension context that has registered a listener and can access that area: the service worker (waking it if needed), every open extension page, and every content script in every tab (for areas content scripts can read). Each event lists the changed keys with oldValue and newValue, plus the area name. Writes that do not change a value still fire events in some versions, and a single set call with several keys fires one event containing all of them. Nothing is applied automatically — each context must react — and contexts that cached the setting in a variable without listening stay stale.
Step-by-step: settings that apply everywhere
1. Define settings with types and defaults in one module
1// shared/settings.js
2export const DEFAULTS = Object.freeze({
3 highlightPrices: true,
4 theme: "system", // "light" | "dark" | "system"
5 fontScale: 1,
6 disabledSites: [],
7});
8
9export async function getSettings() {
10 const stored = await chrome.storage.sync.get(Object.keys(DEFAULTS));
11 return { ...DEFAULTS, ...stored };
12}
13
14export function onSettingsChanged(callback) {
15 const listener = (changes, area) => {
16 if (area !== "sync") return;
17 const patch = {};
18 for (const [k, { newValue }] of Object.entries(changes)) if (k in DEFAULTS) patch[k] = newValue ?? DEFAULTS[k];
19 if (Object.keys(patch).length) callback(patch);
20 };
21 chrome.storage.onChanged.addListener(listener);
22 return () => chrome.storage.onChanged.removeListener(listener);
23}
Execution context: a module shared by every context. Merging stored values over defaults means new settings work for existing users without a migration. newValue ?? DEFAULTS[k] handles keys being removed (reset to default). Filtering by known keys ignores unrelated storage changes such as caches or job records.
2. React in the service worker
1// sw.js
2import { getSettings, onSettingsChanged } from "./shared/settings.js";
3
4onSettingsChanged(async (patch) => {
5 if ("highlightPrices" in patch || "disabledSites" in patch) await syncContentScriptRegistration();
6 if ("theme" in patch) await updateActionIcon();
7});
Execution context: the service worker, registered at the top level so a change made while the worker sleeps wakes it. The worker applies settings that live outside any page: dynamic content script registrations, declarativeNetRequest rules, alarm schedules and the action icon. Read settings fresh with getSettings() when an event arrives rather than relying on a module-level copy that may predate the last eviction.
3. React in content scripts
1// content.js
2import { getSettings, onSettingsChanged } from "./shared/settings.js";
3
4let settings = await getSettings();
5apply(settings);
6
7onSettingsChanged((patch) => {
8 settings = { ...settings, ...patch };
9 if (settings.disabledSites.includes(location.hostname) || !settings.highlightPrices) teardown();
10 else apply(settings);
11});
Execution context: a content script (bundled as a classic script with the shared module inlined). Content scripts can read sync and local by default, so they receive change events directly — the worker does not need to message every tab. Applying changes in place means the user sees the effect immediately on the page they are looking at.
4. React in extension pages
1// popup.js
2const settings = await getSettings();
3render(settings);
4const stop = onSettingsChanged((patch) => render({ ...settings, ...Object.assign(settings, patch) }));
5addEventListener("pagehide", stop);
Execution context: the popup, side panel or options page. A popup left open while the user changes settings elsewhere — in another window’s options page, or on another device through sync — updates live. Removing the listener on pagehide is tidy, though the listener dies with the page anyway.
5. Avoid write loops
1onSettingsChanged(async (patch) => {
2 if ("fontScale" in patch && (patch.fontScale < 0.5 || patch.fontScale > 3)) {
3 const clamped = Math.min(3, Math.max(0.5, patch.fontScale));
4 if (clamped !== patch.fontScale) await chrome.storage.sync.set({ fontScale: clamped }); // writes once, then stable
5 }
6});
Execution context: the service worker. A listener that writes to storage in response to a change triggers another change event — harmless if the second write is a no-op, an infinite loop if each write produces a new value. Only write when the corrected value differs, and do corrections in exactly one context (the worker), not in every listener.
6. Debounce bursts from sync
When Chrome Sync delivers several changes at once — a new device catching up — onChanged may fire many times in quick succession. Contexts that do expensive work on change (rebuilding rules, re-scanning a page) should debounce: collect patches for 100–300 milliseconds, then apply once.
7. Test propagation, not just storage
An end-to-end test should change a setting in the options page and assert the effect in a content script on an already-open tab and in an open popup, without reloads. Unit tests of getSettings prove storage works; only propagation tests prove the extension behaves.
Common mistakes
- Reading settings once at startup. Every context goes stale.
- Messaging every tab on change. Storage events already reach content scripts.
- Unfiltered
onChangedhandlers. Caches and job records trigger unnecessary work. - Correcting values in every listener. Write loops; correct in one place.
- Settings in
chrome.storage.session. Content scripts cannot read it by default, and it does not persist.
Cross-browser variation
- Chrome / Edge:
onChangedfires in all contexts with access to the area, including content scripts forsyncandlocal. - Firefox: same behaviour;
storage.syncsyncs through Firefox Sync when enabled. - Safari:
onChangedworks across contexts;storage.syncdoes not sync between devices in all versions, so treat it as local plus a promise.
Verification
- Open a page with the content script active and the popup; toggle the setting in options and confirm both update without reload.
- Stop the service worker, change a setting, and confirm the worker wakes and applies it.
- Change a setting on a second synced device and confirm the first device updates.
- Set an out-of-range value and confirm it is clamped once without a loop.
FAQ
Do I need to listen in both onChanged and the area-specific chrome.storage.sync.onChanged?
No. The global event with an area argument is enough; area-specific events exist as a convenience.
Will content scripts in background tabs receive changes?
Yes, as long as the tab’s page is loaded. Discarded tabs re-read settings when they reload.
Should the options page save on every keystroke?
Debounce text inputs; write toggles immediately. See auto-save vs explicit save in options.
How do I keep the options page itself in sync when another device changes a setting?
Listen to onChanged in the options page too and update form controls — but not the one the user is currently editing, or their typing will be overwritten mid-word. Track the focused control and skip it, or show a small “changed on another device” note.
Is chrome.storage.local change propagation the same?
Yes, within this browser profile. Only sync changes also arrive from other devices.
Related
- Syncing options form state with chrome.storage — the writing side.
- Storage onChanged listener patterns — event handling in depth.
- Per-site settings and overrides — the disabledSites pattern expanded.
- Options page configuration — the parent topic.