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.

Published June 19, 2026 Updated June 19, 2026 10 min read
Table of Contents

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.

Why storage sync in options pages is harder than it looks

The form and chrome.storage.sync are two separate sources of truth, and they can diverge in several ways:

  1. Another tab is open. The user has the options page open in two tabs. A change in tab A must propagate to tab B without a refresh.
  2. The service worker updated a key. A background rule or scheduled task wrote to storage. The options page must reflect that without polling.
  3. The write failed. Quota was exceeded, or the network was offline (for sync). If the form does not roll back to the last confirmed value, it shows data that was never actually saved.
  4. The user types fast. Naïve 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.

Step 1 — Read and hydrate on mount

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.

Step 2 — Debounced write on input change

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.

Step 3 — onChanged listener for cross-tab and cross-context reactivity

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.

Step 4 — Status indicator

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.

Cross-browser variation

  • Chrome / Edge: 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.
  • Firefox: Use 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.
  • Safari: Safari 15.4+ supports 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.

Verification

  1. Open the options page, change a setting, wait 500 ms. Open DevTools → Application → Extension Storage → Sync. Confirm the new value is present.
  2. Open the options page in two tabs. Change a setting in tab A. Within one second, confirm tab B reflects the change without a reload.
  3. Simulate a quota error: in DevTools console, temporarily override 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.
  4. Type rapidly in a text input for 2 seconds. Confirm DevTools shows only one or two storage writes, not one per keystroke.
  5. Open the service worker DevTools (chrome://extensions → Inspect views → service worker), run chrome.storage.sync.set({ theme: 'dark' }). Confirm the options page updates without a reload.

FAQ

Should I use chrome.storage.local or chrome.storage.sync for options?

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 onChanged listener fires in a loop — how do I stop it?

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.

Can I use a schema library like Zod to validate before saving?

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.

Does the debounce need to flush before the page unloads?

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'.

Other UI/UX Patterns & Interactive Components Resources