DEV Community

Temir Kussainov
Temir Kussainov

Posted on AI-assisted

Syncing a browser extension's settings without a server: three reviews, three ways to lose data

Headerwise is a header editor for Chrome and Edge I wrote after ModHeader got pulled from the stores. People asked for one thing ModHeader charged for: having the same profiles on the work laptop and the home desktop.

I didn't want a server. The extension's manifest forbids network requests (connect-src 'none'), and I'd like to keep it that way. Luckily the browser already has sync: chrome.storage.sync goes through the Google (or Microsoft, in Edge) account the browser is signed in to, and the extension never talks to anything itself.

So the job looked small: copy the profiles into chrome.storage.sync, copy them back out on the other computer. It took four rounds of review to get right, and the first three each found a way to lose someone's profiles. Here's what broke, in case you're about to do the same thing.

The limits shape the design

chrome.storage.sync gives you 100 KB in total, 8 KB per item, 512 items, and 120 writes a minute. A profile with a few headers is a few hundred bytes, so one item per profile works, and it means two computers editing different profiles never touch the same item.

Some things shouldn't sync at all: whether a profile is switched on (turning on a production header at work shouldn't turn it on at home), "only in this tab", the timer, and secret values. Tokens and cookies are stripped before anything leaves the computer, and each computer keeps its own.

Version 1: push and pull. Lost edits.

Push: write every profile and an index of ids. Pull: replace local profiles with what's in sync; anything I'd seen before that is gone from the index was deleted elsewhere.

The review found the problem fast. A write to chrome.storage.sync fires onChanged on the same computer too, so a pull follows every push. If you type again between the push and that pull, the pull puts back the version you just pushed. Your text jumps back while you type.

Worse, "gone from the index" isn't the same as "deleted". The browser keeps the last write of each key. If computer B writes its index before it has seen the profile A just created, A's profile is missing from the index, and A dutifully deletes it.

Version 2: a three-way merge. Lost edits, differently.

The fix for the first problem is old: keep a base. Each computer remembers every profile as it was when it and sync last agreed. Then a round can tell who changed what:

  • local equals base, sync differs: changed elsewhere, take it in;
  • sync equals base, local differs: changed here, send it;
  • both differ: changed on both, keep both versions (the other one as a copy, so nothing disappears silently).

And deletions became explicit marks instead of "missing from the index". Profiles exist as items; the order is just an order.

The second review modelled each computer with its own copy of sync storage and a last-write-wins server, which is how the browser actually behaves, and found that "local equals base" doesn't mean what I thought. A edits a profile and sends it, so A's base is A's version. B, which hadn't seen it yet, sends its own edit. Last write wins: B's. On A, local equals base, sync differs, so "changed elsewhere": A takes B's version and A's edit is gone, with no copy.

Version 3: remember the parent. Old copies win.

So every item got a parent: a fingerprint of the version it was edited from. If what arrives wasn't built on what I last agreed on, both versions are kept.

The third review found that this keeps far too much. A computer that was asleep misses versions all the time. A goes v0 → v1 → v2, B wakes up and sees only v2, whose parent is v1. B's base is v0, so "not built on what I have", and B kept its v0 under the id, sent it, and turned v2 into a copy. Every computer ended up back on v0. With three computers it was worse: each one saw a parent mismatch and resent its own version, forever, until the browser's write quota ran out.

Version 4: only send what changed here

The rule that finally held: a computer sends a profile only if it changed on that computer. If it changed nothing, it takes the newest version, whatever it missed in between. That alone makes every round settle, because writes only follow edits.

The parent check stays, but only for the one case it's for: this computer wrote the version it last agreed on, and something replaced it without having seen it. Then (and only then) both are kept. The base remembers which versions it sent itself for exactly this.

The fourth review ran all 18 timelines from the first three, plus new ones aimed at this rule, and found nothing in the merge. It did find that the popup could drop a token pasted in the same quarter second a change arrived, which is a different story.

What I'd tell myself at the start

  • Model the browser honestly in tests: separate copies of sync per computer, last write wins per key, rounds that don't see each other. My first tests shared one store and ran rounds in a tidy order, and everything passed.
  • "Missing" is not "deleted". Write deletions down.
  • A base tells you who changed what; a parent tells you who saw what. You need both, and only the computer that wrote a version may defend it.
  • Every round should be able to run again and change nothing. If it can't, it will eventually run forever.

The code is MIT and the merge is one file, src/core/sync.ts, with the timelines as tests in tests/sync.test.ts: https://github.com/Protemir/headerwise. Headerwise itself is free in the Chrome Web Store: https://chromewebstore.google.com/detail/jedoaeaapdofoldmkacjbpojbnpapkna

If you've done sync over chrome.storage.sync differently, I'd like to hear how.

Top comments (0)