Syncing Options Form State with chrome.storage
Two-way bind an MV3 options form to chrome.storage.sync — debounced autosave, onChanged cross-tab reactivity, optimistic UI, and quota error handling.
Two-way bind an MV3 options form to chrome.storage.sync — debounced autosave, onChanged cross-tab reactivity, optimistic UI, and quota error handling.
The most common bug in extension options pages is a form that reads from chrome.storage.sync on load but does not write back reliably — or writes on every keypress and hits the rate limit. The result is settings that silently revert, inputs that feel laggy, or a console full of MAX_WRITE_OPERATIONS_PER_MINUTE errors. This guide builds a production-grade two-way binding: read on mount, debounced write on change, onChanged listener to react when another tab or the service worker updates the same keys, and optimistic UI with rollback on error. It is part of the options page layouts guide.
The form and chrome.storage.sync are two separate sources of truth, and they can diverge in several ways:
input event handlers write on every keystroke. At 120 writes per minute (Chrome’s sync rate limit), a fast typist exhausts the quota in seconds.The solution is a single unidirectional flow: form events → debounced write → storage → onChanged → form update. The form never mutates its own displayed value directly; it waits for the storage echo.
Never block the DOM waiting for storage. Show a loading skeleton or disable controls until the read resolves, then populate.
1// storage-sync.js (ES module loaded in options.html)
2
3const DEFAULTS = {
4 theme: 'system',
5 notifications: true,
6 syncInterval: 30,
7 language: 'en',
8};
9
10export async function hydrateForm() {
11 const keys = Object.keys(DEFAULTS);
12 let stored;
13 try {
14 stored = await chrome.storage.sync.get(keys);
15 } catch (err) {
16 console.error('Failed to read settings:', err);
17 stored = {};
18 }
19
20 // merge with defaults so missing keys never break the form
21 const settings = { ...DEFAULTS, ...stored };
22
23 // populate controls
24 document.getElementById('inp-theme').value = settings.theme;
25 document.getElementById('inp-notifications').checked = settings.notifications;
26 document.getElementById('inp-sync-interval').value = settings.syncInterval;
27 document.getElementById('inp-language').value = settings.language;
28
29 // remove loading state
30 document.getElementById('opts-form').removeAttribute('aria-busy');
31}
Execution context: Options page renderer process. chrome.storage.sync.get is asynchronous and returns a Promise in MV3. Passing an array of keys fetches all of them in a single IPC round-trip — cheaper than multiple individual get calls. Firefox exposes the same API under browser.storage.sync; use the WebExtensions polyfill or the (typeof browser !== 'undefined' ? browser : chrome) runtime check for cross-browser builds.
Call hydrateForm() at the module’s top level (modules run after DOM is ready):
1// options.js
2import { hydrateForm, scheduleSave } from './storage-sync.js';
3import { attachOnChanged } from './storage-listener.js';
4
5hydrateForm();
6attachOnChanged();
Execution context: Options page renderer, entry module. Top-level await is valid in ES modules but synchronises the entire module graph — hydrateForm() returns a Promise, so call it without await here and let the UI skeleton handle the async gap.
Attach one input listener to the form rather than to each control individually (event delegation). Debounce at 400 ms — short enough to feel responsive, long enough to batch rapid keystrokes into a single write.
1// storage-sync.js (continued)
2
3let saveTimer = null;
4let pendingWrites = {}; // accumulate changes across multiple rapid inputs
5let lastConfirmed = {}; // last values confirmed by a successful write
6
7export function scheduleSave(key, value) {
8 pendingWrites[key] = value;
9 clearTimeout(saveTimer);
10
11 // show optimistic feedback immediately
12 setStatus('Saving…', 'pending');
13
14 saveTimer = setTimeout(async () => {
15 const writes = { ...pendingWrites };
16 pendingWrites = {};
17
18 try {
19 await chrome.storage.sync.set(writes);
20 lastConfirmed = { ...lastConfirmed, ...writes };
21 setStatus('Saved', 'ok');
22 } catch (err) {
23 // rollback optimistic UI
24 setStatus('Save failed — ' + friendlyError(err), 'error');
25 rollbackForm(lastConfirmed);
26 }
27 }, 400);
28}
29
30function friendlyError(err) {
31 const msg = String(err?.message ?? err);
32 if (msg.includes('QUOTA_BYTES_PER_ITEM')) return 'a single value is too large (8 KB limit)';
33 if (msg.includes('QUOTA_BYTES')) return 'total settings storage is full';
34 if (msg.includes('MAX_WRITE_OPERATIONS')) return 'too many changes — slow down';
35 return msg;
36}
37
38function rollbackForm(confirmed) {
39 if (confirmed.theme !== undefined) document.getElementById('inp-theme').value = confirmed.theme;
40 if (confirmed.notifications !== undefined) document.getElementById('inp-notifications').checked = confirmed.notifications;
41 if (confirmed.syncInterval !== undefined) document.getElementById('inp-sync-interval').value = confirmed.syncInterval;
42 if (confirmed.language !== undefined) document.getElementById('inp-language').value = confirmed.language;
43}
Execution context: Options page renderer. pendingWrites accumulates all changed keys during the debounce window, so a single set() call commits all dirty keys in one round-trip. The lastConfirmed object is the rollback source of truth — it tracks only values that have successfully landed in storage.
Wire the form:
1// options.js (continued)
2document.getElementById('opts-form').addEventListener('input', e => {
3 const el = e.target;
4 if (!el.name) return;
5
6 let value;
7 if (el.type === 'checkbox') value = el.checked;
8 else if (el.type === 'number') value = Number(el.value);
9 else value = el.value;
10
11 scheduleSave(el.name, value);
12});
Execution context: Options page renderer. Delegating to the <form> element means new controls added to the form are automatically covered. Use name attributes on all inputs so the storage key matches the form field without a separate mapping.
When the service worker or another options tab writes to chrome.storage.sync, the onChanged event fires in every extension context simultaneously. Register the listener at the top level of your module so it is active as long as the options page is open.
1// storage-listener.js
2
3export function attachOnChanged() {
4 chrome.storage.onChanged.addListener((changes, areaName) => {
5 if (areaName !== 'sync') return;
6
7 // Guard: if the change is echoing our own write, skip (prevents feedback loops)
8 if (Object.keys(changes).every(k => changes[k].newValue === lastConfirmed[k])) return;
9
10 // Apply external changes to the form
11 if (changes.theme) {
12 document.getElementById('inp-theme').value = changes.theme.newValue;
13 }
14 if (changes.notifications !== undefined) {
15 document.getElementById('inp-notifications').checked = changes.notifications.newValue;
16 }
17 if (changes.syncInterval !== undefined) {
18 document.getElementById('inp-sync-interval').value = changes.syncInterval.newValue;
19 }
20 if (changes.language) {
21 document.getElementById('inp-language').value = changes.language.newValue;
22 }
23
24 setStatus('Updated from another session', 'info');
25 setTimeout(() => setStatus('', ''), 2500);
26 });
27}
Execution context: Options page renderer. chrome.storage.onChanged fires in the options page, the service worker, and any content script that holds the storage permission. The feedback-loop guard checks whether the incoming value matches lastConfirmed — if so, this is the echo of our own write and can be ignored. Firefox and Safari fire the same event; Safari may coalesce rapid changes into a single callback on slow connections.
A non-modal, non-blocking status bar at the bottom of the form is the clearest save UX for a full-page options UI. Keep it out of the main content flow so it does not cause layout shifts.
1// storage-sync.js (continued)
2function setStatus(message, type) {
3 const el = document.getElementById('opts-status');
4 if (!el) return;
5 el.textContent = message;
6 el.className = `opts-status opts-status--${type}`;
7 el.hidden = !message;
8
9 // announce to screen readers
10 if (type === 'error') {
11 el.setAttribute('role', 'alert'); // assertive
12 } else {
13 el.setAttribute('role', 'status'); // polite
14 el.removeAttribute('aria-live');
15 }
16}
Execution context: Options page renderer. role="alert" triggers an assertive ARIA live region — use it only for errors. role="status" is polite and does not interrupt the user mid-sentence. Chrome, Firefox, and Safari all support both roles in their respective screen reader pairings.
The matching HTML element:
1<!-- inside options.html, after the form -->
2<div id="opts-status" class="opts-status" hidden role="status" aria-live="polite"></div>
Execution context: Rendered in the options page tab. The hidden attribute starts the element off-screen so it is not announced on load. The JS toggles it as needed.
chrome.storage.sync is Promise-based in MV3 with automatic conflict resolution across signed-in profiles. Rate limit is ~120 writes/minute and ~1,800/hour. onChanged fires synchronously within the same tab immediately after a write (before the Promise resolves in some builds) — do not rely on ordering.browser.storage.sync (native Promises) or the WebExtensions polyfill. Firefox 109+ supports MV3 chrome.storage.sync; earlier Firefox requires browser.storage.sync. The onChanged event fires with the same signature; Firefox’s sync backend is Mozilla Sync rather than Google Account, so write-rate limits may differ.chrome.storage.sync mapped to iCloud. The per-extension sync quota is lower than Chrome’s and subject to iCloud throttling. If the options page is open while the device is offline, set() may succeed locally but fail to propagate — the onChanged event fires when the value is committed to iCloud, which can be delayed. Test offline behaviour explicitly.chrome.storage.sync.set to reject with new Error('QUOTA_BYTES_PER_ITEM exceeded'). Trigger a save and confirm the form rolls back to the previous value and shows an error message.chrome://extensions → Inspect views → service worker), run chrome.storage.sync.set({ theme: 'dark' }). Confirm the options page updates without a reload.Use sync for user preferences that should follow them across devices (theme, language, notification settings). Use local for data that is device-specific or too large for sync (caches, large blobs). The Chrome Storage API & Sync guide covers the quota and performance trade-offs in detail.
The most common cause is that your listener writes back to storage in response to a change, which triggers another change. Either use the lastConfirmed guard shown in Step 3, or compare changes[key].newValue to the current form value before writing. Never write inside an onChanged handler without a strict equality check first.
Yes, and it is recommended for complex settings. Validate the proposed value before passing it to scheduleSave. If validation fails, call setStatus('Invalid value', 'error') and bail out — do not write an invalid value to storage. Schema validation also protects against corrupted data when reading, where a migration may be needed.
Not usually — the beforeunload event is unreliable in extensions and should not be used. The 400 ms debounce means the write is almost always committed well before any user action that closes the tab. If you have genuinely critical unsaved state (a long text field), add a visibilitychange listener and flush the pending writes when document.visibilityState becomes 'hidden'.
onChanged event in depth.Implement persistent side panels and DevTools extension panels in MV3 using chrome.sidePanel and chrome.devtools.panels — per-tab control, lifecycle, and cross-browser gaps.
Build chrome.contextMenus items in MV3: register in onInstalled, handle onClicked in the service worker, create parent/child menus, and gate visibility by context type.
Register and handle keyboard shortcuts in MV3 extensions using the chrome.commands API — manifest declaration, service worker listeners, per-OS suggested keys, the 4-shortcut limit, and cross-browser rebinding via chrome://extensions/shortcuts.
Design scalable options page UIs for MV3 extensions — sidebar nav, tabbed sections, responsive grids, form components, autosave UX, and dark mode support.