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.

Published October 2, 2026 Updated October 2, 2026 8 min read
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.

The durable state a sync engine keepsThe local data store, the outbox of unacknowledged operations, the pull cursor, and the in-flight lock, each persisted so that sync resumes correctly after any termination.itemslocal copy of user datastorage.local / IndexedDBoutboxops not yet acknowledgedappend-only queuecursorlast server change appliedopaque tokensyncLockowner + expiryprevents overlap
Everything sync needs to resume lives in storage, not in variables.

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.

A sync roundThe worker takes the sync lock, pushes outbox operations, removes the acknowledged ones, pulls server changes since the cursor, applies them, saves the new cursor and releases the lock.Service workerstorage.localAPIacquire syncLockPOST /sync/ops (outbox)applied / duplicate / conflictdrop acknowledged opsGET /sync/changes?cursor=c41changes + cursor c47apply, save cursor, release lock
Push before pull, so the server's answer already includes your own changes.

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.

Choosing a conflict ruleDecision tree for conflict resolution: settings use last writer wins, multi-field records use field-level merge, and free text that both sides edited goes to the user.What kind of data conflicted?settingsLast writer winsby server timestampSilentloss is acceptablerecordsField-level mergetouched fields keep localSilentunless same fieldfree textKeep bothconflict copyAsk the usershow both versions
The right rule depends on what the data is, not on what is easiest to code.

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.local is limited to 10 MB unless you request unlimitedStorage; 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

  1. Make an edit offline. Confirm the item shows pending: true and the outbox holds one operation.
  2. Go online and trigger sync. Confirm the outbox empties and the server holds the edit.
  3. Make an edit, then stop the worker from chrome://serviceworker-internals immediately after the push request starts. Restart and sync: the server must hold exactly one copy of the edit.
  4. 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.

Other Core APIs & Cross-Browser Data Management Resources