This post is an AI translation of the Chinese original. If there are any discrepancies, the Chinese version takes precedence.
For work reasons, I live with two laptops: a company machine for email and day-to-day office work, and a personal machine for everything that doesn’t fit inside corporate permissions.
Firefox has been my main browser for years. But our IT policy blocks signing in to personal accounts in any browser. That’s a real, everyday annoyance: bookmarks I save while researching on my personal laptop aren’t there on my work machine, and useful links I stash at the office won’t open when I get home.
I tried the usual community options like floccus. They’re built for general use, though — lots of features, lots of setup and dependencies. What I wanted was much narrower: stable two-way sync across a few machines, with data living only on my own VPS. Nothing extra.
Since nothing quite fit, I wrote my own tool and called it bmsync. It’s a tiny Firefox extension plus a lightweight backend. Each click on the extension icon runs a full two-way sync of the bookmark tree across devices, and data never leaves my own server.
Here’s a record of the design thinking, the trade-offs, and a mid-course change of direction.
Overall Design: Server-Side Merge and Full-State Transfer
The easiest way to get multi-device sync wrong is to let each client compute its own diff. Once two sides disagree about state, merge logic turns into a mess fast.
To keep things simple, I made the browser extension almost dumb. It only does two things:
- Collect: read the local bookmark tree, turn it into a full snapshot, and send it to the server;
- Apply: take the latest complete snapshot back from the server and overwrite local bookmarks with it.
All merge logic lives on the server. That gives me idempotency for free: retries after a flaky network, an accidental double-click, or two machines syncing back-to-back all end up at the same result. There’s no risk of two clients racing to merge and corrupting data.
For transport I went with full-state transfer. In practice, 5,000 bookmarks serialize to around 800 KB of JSON, and even less after gzip. On today’s networks, that extra bandwidth is cheap compared to maintaining incremental version numbers and dealing with resume logic and out-of-order updates.
No Global IDs — So How Do We Recognize the Same Bookmark?
Firefox’s Bookmarks API only hands out local auto-increment integer IDs. The same bookmark gets a different ID on each device, and the API won’t store a custom global field for me.
My workaround is to derive a stable identity from content hashes:
- Bookmark key: first 16 bytes of
SHA-256("u:" + url); - Folder key: first 16 bytes of
SHA-256("f:" + parentKey + NUL + title).
There’s a deliberate idea baked in here: a bookmark’s key doesn’t include its parent folder. When you move a bookmark from folder A to folder B, the key itself stays the same — only the parent link changes. So the server reads that as a “move”, not as “delete the old one and create a new one”.
The flip side: if you edit a bookmark’s URL, or rename its parent folder, the system treats that as “delete the old entry plus create a new one”.
Deletes and Tombstones
In sync systems, deletes are trickier than inserts.
If you just compare bookmark lists, and the server sees a bookmark on machine A that’s missing on machine B, it can’t tell which story is true: “machine B just deleted it” or “machine B is a fresh device that hasn’t pulled it yet”.
So a local delete doesn’t physically remove the record. Instead it leaves a tombstone marked with d: true. Tombstones follow the same last-write-wins rule as normal bookmarks and stick around on the server for 90 days. That’s plenty of time for my regular machines to come online and pick up the deletion. Only after 90 days does the server purge expired tombstones for good.
Dodging Clock Skew: Hybrid Logical Clock (HLC)
Once timestamps decide which state wins, physical clock drift between devices becomes a real problem. If one laptop runs a few minutes fast, its older change could steamroll a newer edit from a machine with the correct time.
To take physical drift out of the picture, the project uses an HLC (hybrid logical clock). Once device B has seen device A’s state, every operation B produces afterward carries a logical timestamp strictly greater than A’s.
Both the server and the extension implement HLC on their own side, and a shared set of test cases pins down the behavior so both ends advance logical time the same way.
Stack Choice: From Go to C#
The backend started life in Go. For a personal utility, Go’s single-binary output and small memory footprint were genuinely convenient to deploy.
But when I reviewed the Go code — first sketched out quickly with AI help — it didn’t feel good to maintain. Without strict typing and object-oriented structure, logic sat scattered across flat functions, and each change meant re-tracing who depended on what.
In the end I switched back to C#, the language I know best, and leaned on NativeAOT to keep deployment light:
- Clearer structure: a strong type system with pattern matching and clean layering makes the logic easy to scan. Even coming back after a few months, I can pick it up quickly;
- NativeAOT keeps it lean: it compiles to a single native binary. Packed into a Docker
FROM scratchimage, it’s only 20–30 MB and sips memory at runtime; - The extension stays clean: it’s plain vanilla JavaScript (ES Modules) with no frontend framework, no bundler, and no npm dependencies. After a code change, I just hit refresh in Firefox’s
about:debuggingand it’s live; - Tests keep it honest: a test script (
./test/all.sh) runs 250+ cases, from static checks through end-to-end API verification.
I also kept sync as a manual action on purpose. The extension doesn’t poll in the background — one click, one sync. That kind of predictability is exactly what I want from a small utility.
Known Trade-Offs
Since this is a tool tailored to my own use, I cut a few corners on edge cases quite deliberately:
- No ordering inside folders: synced bookmarks get appended at the end. Content and folder structure stay consistent; order doesn’t;
- URLs are globally unique: if you bookmark the same URL in two folders, sync merges them into one;
- Folder renames are pricey: renaming a folder recomputes keys for the whole subtree. Nothing is lost, but it leaves a batch of old tombstones behind;
- Don’t stay offline past 90 days: if a device stays disconnected for over 90 days, the server will already have purged the tombstones, so it may re-upload its stale bookmarks as if they were new;
- Core bookmarks only: no tags, notes, favicons, or reading-list extras.
Links
Both the server and the extension are open source, and the extension is listed on the official Firefox store:
- Firefox Add-ons: bm-sync on Firefox Add-ons
- GitHub repo: tilv37/sync-bookmark
This isn’t a public SaaS — you’ll need to host the backend yourself. The repo ships a docker-compose.yml. Bring it up, then drop your server URL and access token into the extension’s settings page.
If you’re also stuck syncing bookmarks across locked-down machines and want the data to stay entirely on your own server, give it a try.