Syncing Extension Data with a Backend API
Two-way sync between chrome.storage and your server in an MV3 extension: an outbox for local writes, cursor-based pulls, idempotent operations, conflict rules and alarm-driven scheduling.
Table of Contents
Users expect what they save in the extension on one laptop to appear on the other, and what they change on your website to show up in the extension. chrome.storage.sync cannot carry it — the quota is about 100 KB and it only syncs between the same browser’s profiles — so you build sync against your own API. The naive version fetches everything on popup open and posts every change immediately, and it breaks in predictable ways: edits lost when the worker is terminated mid-request, duplicates when a retry succeeds twice, and the server’s newer copy overwritten by a stale local one. This guide builds the version that survives MV3. It belongs to network requests and backend sync.
Why sync is harder in a service worker
Sync is a protocol between two stores that are each changing. Its correctness depends on knowing, at every moment, which local changes the server has acknowledged and which server changes the client has applied. In a persistent background page those facts could sit in memory. In a service worker, memory is wiped on every eviction — which can happen between sending a request and receiving its response. Any sync design for MV3 therefore keeps both facts in storage: an outbox of local operations not yet acknowledged, and a cursor marking the last server change applied. With those two pieces of durable state, the worker can be killed at any instant and the next run resumes exactly where the last one stopped.
Step-by-step: an outbox-and-cursor sync
1. Record every local change as an operation
1// sync/local.js
2export async function saveItem(item) {
3 const op = {
4 opId: crypto.randomUUID(), // idempotency key
5 kind: "upsert",
6 itemId: item.id,
7 fields: item,
8 baseVersion: item.version ?? 0, // version the edit was based on
9 at: Date.now(),
10 };
11 const { items = {}, outbox = [] } = await chrome.storage.local.get(["items", "outbox"]);
12 items[item.id] = { ...item, pending: true };
13 outbox.push(op);
14 await chrome.storage.local.set({ items, outbox }); // one write: both or neither
15 requestSync("local-change");
16}
Execution context: the service worker, called by message from the popup, side panel or options page. Writing the item and its operation in a single set call keeps them consistent — chrome.storage applies one call atomically. The pending flag lets the UI show an unsynced indicator. crypto.randomUUID is available in service workers in every current engine.
2. Push the outbox with idempotent requests
1// sync/push.js
2export async function pushOutbox() {
3 const { outbox = [] } = await chrome.storage.local.get("outbox");
4 if (outbox.length === 0) return;
5 const batch = outbox.slice(0, 50);
6 const res = await api("/v1/sync/ops", { method: "POST", body: { ops: batch } });
7 // res.results: [{ opId, status: "applied" | "duplicate" | "conflict", item? }]
8 const done = new Set(res.results.filter((r) => r.status !== "conflict").map((r) => r.opId));
9 await resolveConflicts(res.results.filter((r) => r.status === "conflict"));
10 const { outbox: current = [] } = await chrome.storage.local.get("outbox");
11 await chrome.storage.local.set({ outbox: current.filter((op) => !done.has(op.opId)) });
12}
Execution context: the service worker. Re-reading the outbox before removing acknowledged entries avoids discarding operations appended while the request was in flight. The server records each opId it has applied and answers duplicate for repeats, so a request whose response was lost to an eviction can be resent safely. Batching fifty at a time keeps each request well inside the worker’s response-time limits.
3. Pull changes since the cursor
1// sync/pull.js
2export async function pullChanges() {
3 let { cursor = null } = await chrome.storage.local.get("cursor");
4 for (let page = 0; page < 20; page++) {
5 const res = await api(`/v1/sync/changes?cursor=${encodeURIComponent(cursor ?? "")}&limit=200`);
6 const { items = {}, outbox = [] } = await chrome.storage.local.get(["items", "outbox"]);
7 const pendingIds = new Set(outbox.map((op) => op.itemId));
8 for (const change of res.changes) {
9 if (pendingIds.has(change.id)) continue; // local edit wins until pushed
10 if (change.deleted) delete items[change.id];
11 else items[change.id] = { ...change.item, pending: false };
12 }
13 cursor = res.cursor;
14 await chrome.storage.local.set({ items, cursor }); // apply + advance together
15 if (!res.hasMore) break;
16 }
17}
Execution context: the service worker. Advancing the cursor in the same set call that applies the changes means a termination between pages loses nothing — the next run fetches the same page again and applies it idempotently. Skipping items with pending local edits prevents the pull from overwriting an unsent change; the next push resolves them. The page cap stops a first sync of a huge account from monopolising one event.
4. Pick a conflict rule and apply it consistently
1async function resolveConflicts(conflicts) {
2 if (conflicts.length === 0) return;
3 const { items = {} } = await chrome.storage.local.get("items");
4 for (const c of conflicts) {
5 // Rule: server wins for fields the user did not touch; local wins for fields they did.
6 const local = items[c.item.id] ?? {};
7 const touched = new Set(Object.keys(c.op.fields).filter((k) => c.op.fields[k] !== c.base?.[k]));
8 const merged = { ...c.item };
9 for (const k of touched) merged[k] = local[k];
10 items[c.item.id] = { ...merged, version: c.item.version, pending: true };
11 await enqueueRetry(merged, c.item.version);
12 }
13 await chrome.storage.local.set({ items });
14}
Execution context: the service worker. A conflict means the server’s version moved past the baseVersion the local edit started from. Field-level merging handles the common case of two devices editing different fields. Last-writer-wins by timestamp is simpler and acceptable for settings, but it silently discards edits for documents. Whatever rule you choose, apply it on one side only — usually the server — so two clients cannot disagree.
5. Schedule rounds and prevent overlap
1// sw.js
2chrome.alarms.create("sync", { periodInMinutes: 15 });
3chrome.alarms.onAlarm.addListener((a) => a.name === "sync" && runSync("alarm"));
4
5let syncing = null;
6export function requestSync(reason) {
7 syncing ??= runSync(reason).finally(() => { syncing = null; });
8 return syncing;
9}
10
11async function runSync(reason) {
12 if (!(await acquireLock(60_000))) return; // another round is running
13 try { await pushOutbox(); await pullChanges(); }
14 catch (err) { await recordSyncError(reason, err); }
15 finally { await releaseLock(); }
16}
Execution context: the service worker. The in-memory syncing promise coalesces calls within one worker lifetime; the storage lock with an expiry prevents overlap across a quick restart, and expires on its own if a worker died holding it. Trigger sync on local changes, on the alarm, on onStartup, and when a popup opens — and, if you have it, on a server push. The alarm is the safety net that guarantees eventual consistency even if every other trigger is missed.
Cross-browser variation
- Chrome / Edge:
chrome.storage.localis limited to 10 MB unless you requestunlimitedStorage; large datasets belong in IndexedDB. Alarms fire with at least a one-minute period in packed builds. - Firefox: identical storage and alarm APIs with native promises. Firefox’s event-page background may stay alive longer, which hides lock bugs — test with forced restarts.
- Safari: alarms are delayed more aggressively when the system is idle, so the sync interval is a lower bound. Trigger sync when an extension page opens so users see fresh data regardless.
Verification
- Make an edit offline. Confirm the item shows
pending: trueand the outbox holds one operation. - Go online and trigger sync. Confirm the outbox empties and the server holds the edit.
- Make an edit, then stop the worker from
chrome://serviceworker-internalsimmediately after the push request starts. Restart and sync: the server must hold exactly one copy of the edit. - Edit the same item on two devices, sync both, and confirm the conflict rule produced the expected merged result on both.
1const { outbox, cursor, items } = await chrome.storage.local.get(["outbox", "cursor", "items"]);
2console.log(outbox.length, cursor, Object.values(items).filter((i) => i.pending).length);
Execution context: the service worker console. After a successful round, expect 0, a non-null cursor, and 0 pending items.
FAQ
Can I use chrome.storage.sync for this instead?
Only for small settings. Its quotas (about 100 KB total, 8 KB per item, limited writes per minute) and its scope (same browser vendor, same signed-in account) make it unsuitable for user content.
Should the popup call the API directly?
It can read fresh data on open, but writes should go through the worker’s outbox. A popup can close at any moment, taking an in-flight request with it.
How do I handle deletions?
As operations like any other — { kind: "delete", itemId } — and as tombstones in the pull stream. Removing an item locally without an operation means the next pull resurrects it.
Related
- Handling offline and retrying requests — the retry policy for the outbox.
- Resolving concurrent writes in chrome.storage — keeping the local store consistent.
- Persisting job progress across worker restarts — the same durability idea for long jobs.
- Network requests and backend sync — the parent topic.