Syncing Options Across a User's Devices
Make extension settings follow the user with chrome.storage.sync — what to sync and what not to, conflict behaviour, per-device overrides, and what happens when sync is off.
Table of Contents
chrome.storage.sync looks like storage.local with a free cloud attached. It is not free — it has a 100 KB total cap, an 8 KB per-item cap and a write rate limit — and it is not always there: users with browser sync disabled get a sync area that silently behaves as local storage. Designing settings that sync well means deciding which settings are about the user and which are about the machine. This guide is part of options page configuration.
What should follow the user
Step-by-step
1. Split the settings object by where it belongs
1// settings.js
2export const SYNCED_DEFAULTS = { theme: "auto", lang: "auto", enabledOrigins: [], showBadge: true };
3export const LOCAL_DEFAULTS = { panelWidth: 360, lastTab: "general" };
4
5export async function readAll() {
6 const [{ settings: s = {} }, { deviceSettings: d = {} }] = await Promise.all([
7 chrome.storage.sync.get("settings"),
8 chrome.storage.local.get("deviceSettings"),
9 ]);
10 return { ...SYNCED_DEFAULTS, ...s, ...LOCAL_DEFAULTS, ...d };
11}
Execution context: any extension page or the service worker. Returning one merged object keeps the UI simple; only the write path needs to know which area a key lives in.
2. Route each write to the right area
1const SYNCED_KEYS = new Set(Object.keys(SYNCED_DEFAULTS));
2
3export async function writeSetting(key, value) {
4 const area = SYNCED_KEYS.has(key) ? "sync" : "local";
5 const storeKey = area === "sync" ? "settings" : "deviceSettings";
6 const { [storeKey]: current = {} } = await chrome.storage[area].get(storeKey);
7 await chrome.storage[area].set({ [storeKey]: { ...current, [key]: value } });
8}
Execution context: any extension page. Writing the whole object under one key keeps the operation count to one per change, which matters on sync; the rate limits are covered in batching storage writes to stay under quota.
3. Keep the synced object small
The per-item cap of 8 KB is the one that bites. A user’s list of enabled sites is the usual culprit — a hundred origins at forty characters each is already half the budget.
1async function checkSyncBudget() {
2 const bytes = await chrome.storage.sync.getBytesInUse("settings");
3 if (bytes > 6 * 1024) console.warn(`[settings] synced object at ${bytes} bytes; cap is 8192`);
4 return bytes;
5}
Execution context: the options page, after any write that grows a list. When a list genuinely needs to be large, split it across several keys (sites:0, sites:1…) each under the per-item cap, and keep the total under 100 KB.
4. React to changes from other devices
A change made on another machine arrives as an ordinary storage.onChanged event with areaName: "sync". Handle it exactly like a local change.
1chrome.storage.onChanged.addListener((changes, area) => {
2 if (area === "sync" && changes.settings) {
3 applySettings({ ...SYNCED_DEFAULTS, ...changes.settings.newValue });
4 }
5});
Execution context: any extension context with the listener registered — including the service worker, which may need to re-register content scripts when enabledOrigins changes remotely. That reconcile is described in registering content scripts at runtime.
5. Do not depend on sync being on
When the user has browser sync disabled or is not signed in, storage.sync still works — it simply never leaves the machine. No error, no event, no flag. That is the correct behaviour, and it means the extension must never tell the user their settings “are synced” as a fact.
1<p class="hint">Settings follow you to other computers where you're signed in to the browser with sync turned on.</p>
Execution context: static copy in the options page. There is no API that tells an extension whether browser sync is active, so the honest wording is conditional.
How conflicts actually resolve
Two devices change the same setting while offline, then both come online. storage.sync resolves this per key with last-writer-wins, where “last” is determined by the sync service rather than by either device’s clock. There is no merge and no conflict event.
For scalar settings that is fine — the user’s most recent choice wins. For lists stored under one key it is not: device A adds a site, device B removes a different one, and whichever write reaches the server last silently discards the other device’s change.
1// Store list items as individual keys so concurrent edits touch different keys.
2async function enableOrigin(origin) {
3 await chrome.storage.sync.set({ [`site:${origin}`]: { enabled: true, at: Date.now() } });
4}
5
6async function listOrigins() {
7 const all = await chrome.storage.sync.get(null);
8 return Object.entries(all)
9 .filter(([k, v]) => k.startsWith("site:") && v.enabled)
10 .map(([k]) => k.slice(5));
11}
Execution context: any extension page. One key per item turns a lost-update race into two independent writes that both survive. The trade is more keys against the 512-item cap on sync, which is comfortably above what a site list needs.
First run on a second device
A user installs the extension on a second machine. onInstalled fires with reason: "install", and a naive first-run routine writes defaults into storage.sync — overwriting the settings the user carefully configured on the first machine before the sync service has delivered them.
1chrome.runtime.onInstalled.addListener(async ({ reason }) => {
2 if (reason !== "install") return;
3 const { settings } = await chrome.storage.sync.get("settings");
4 if (!settings) {
5 // Do NOT write defaults to sync here — readAll() already falls back to them.
6 await chrome.storage.local.set({ firstRunAt: Date.now() });
7 }
8});
Execution context: the service worker. Defaults belong in code, merged at read time, never written to sync speculatively. The first-run page described in showing a first-run setup page after install should likewise wait a few seconds and re-read before assuming the user has no settings.
Cross-browser variation
- Chrome / Edge:
storage.syncrides on browser sync for the signed-in profile. Limits: 100 KB total, 8 KB per item, 512 items, 120 writes per minute. Without sync enabled it silently acts as local storage. - Firefox:
browser.storage.syncsyncs through Firefox Accounts when the user has add-on data sync enabled. Limits are comparable; the write limits are enforced differently and reported with different messages. - Safari:
storage.syncmaps onto iCloud and can take much longer to converge. Keep synced payloads small and never assume a value written on one device is visible on another within a session. - All three: extensions installed on different browsers do not sync with each other. Chrome and Firefox copies of the same extension have entirely separate
syncareas.
Verification
- Confirm the split: after changing a synced setting and a device setting, inspect both areas:
1({ sync: await chrome.storage.sync.get(null), local: await chrome.storage.local.get("deviceSettings") });
Execution context: the options page console. panelWidth must appear only in local; theme only in sync.
- Check the synced size stays well under the per-item cap as lists grow.
- On two signed-in profiles, change the same setting on both and confirm the last change wins; edit the site list on both and confirm neither change is lost.
- Install on a fresh profile signed in to the same account and confirm existing settings appear rather than being reset to defaults.
FAQ
Can I tell whether browser sync is enabled?
No. There is no API for it, deliberately. Word the UI conditionally and do not build features that depend on sync actually happening.
Should I sync the user’s enabled-sites list?
Usually yes — it is a description of the user’s intent. But remember the host permissions it depends on do not sync; each device must grant them separately, which is why reconciling against granted permissions matters.
What if a synced write fails with a quota error?
Keep the change locally, show a quiet warning, and retry later. A settings change should never be lost because the sync budget was exhausted.
Related
- Defaulting and versioning an options schema — the defaults this relies on.
- Syncing options form state with chrome.storage — the form side of the same data.
- Local vs sync storage performance comparison — the measured costs.
- Options page configuration — the parent guide.