I spent 5 months building a Local-First Sync Engine, then Chrome deleted my data.

I went all-in on local-first with CRDT sync for privacy. 5 months and 8,700 lines later, I replaced it with encrypted server storage. Same privacy, 70% less code.

  I Spent 5 Months Building a Local-First Sync Engine. Then Chrome Deleted My Data.

I spent 5 months building a Local-First Sync Engine, then Chrome deleted my data.

I built a crypto net worth tracker where your data never leaves your device. Then Chrome evicted my IndexedDB and my financial history vanished. That's when I started questioning everything about my architecture.

This is the story of how I went all-in on local-first, spent 5 months building a CRDT-based sync engine, and ended up replacing it with something much simpler that gives users the same privacy guarantees. Turns out E2E encryption and server storage aren't opposites.

TL;DR: Local-first + multi-device sync forced CRDT complexity. The free tier forced two parallel architectures. Browser storage eviction killed my confidence in the whole approach. I switched to server-stored encrypted entities with simple versioned sync. Same zero-knowledge privacy, 70% less code.


Why I Went Local-First

September 2025. I'm building UpdraftFi, a crypto net worth tracker. Privacy is the whole point. Crypto users can be paranoid about others seeing their full portfolio, and I wanted to respect that. So I went with: zero-knowledge, everything stored in IndexedDB, the server never sees a single number.

Single-device local-first is a beautiful architecture: no server round-trips, instant UI, your app loads from IndexedDB and renders immediately. I had a free tier (manual snapshots, fully local) and a Pro tier (automatic server-side), two clean paths through the code.

The problems started when I wanted multi-device sync.

Why Delta Sync Was the Right Call (At the Time)

The moment you add a second device to a local-first app, you need a sync engine.

If IndexedDB is your source of truth, you can't just download the latest state from a server because there is no single server state. Each device has its own truth, and the only way to reconcile is to exchange what changed (deltas) and merge them. That's what CRDTs do.

So I built one. Every mutation creates an encrypted, hash-chained delta with merge ordering. It was the architecturally honest solution to the problem I'd created by going local-first.

It worked. For a while.

The Three Bugs That Broke Me

1. I Locked Myself Out of My Own Data

Each browser generated its own encryption keypair, so device A encrypted data that device B couldn't decrypt.

When you're building local-first, "generate keys on the device" is the natural pattern, but there's no built-in way to share a private key across devices. That's the whole point of private keys.

I deleted my own IndexedDB during debugging, generated a new key, and permanently locked myself out of my production data. I had to build an entire Master Encryption Key system with Argon2id key derivation and passphrase-based recovery. All to solve what's essentially a non-problem in server-stored architectures.

2. 20,478 Deltas for 2,398 Entities

After a few months of normal use, the delta log had an 8.5x ratio of operations to actual data. Every sync had to fetch, decrypt, and replay all of them. Haven't logged in for a few days? Wait 1-2 minutes staring at a spinner before you can see your net worth. For a dashboard that shows one number.

I built compaction (squash old deltas into checkpoints), auto-compaction triggers, pagination... all to manage a problem that doesn't exist if you just store current state.

3. The Echo Loop

You delete a record, which triggers a cleanup function, which writes to IndexedDB, which creates a new delta, which syncs to the other device, which applies it and triggers cleanup, which creates another delta...

I built a "suppression depth counter", a re-entrant flag that prevents delta creation during sync. The kind of infrastructure you build when your architecture is fighting itself.

There were more: null vs undefined causing "236 changes for review" popups, deletion markers that wiped entity names, restore barriers to prevent stale deltas from resurrecting deleted records. Each bug fix added more infrastructure.

I wrote 19 sync-related workbooks in 5 months, roughly one crisis per week, each significant enough to need its own planning document. The IndexedDB schema hit version 59.

The Day the Browser Deleted My Data

Browsers can evict IndexedDB data under storage pressure, and it happened to me on my mobile. I opened UpdraftFi and my financial history was completly wiped.

I thought about my free-tier users. If this happens to someone who never made a manual backup (most probably don't), they lose everything. Months of snapshots, wallet history, net worth tracking. Gone.

I couldn't ship a financial product where the storage layer might randomly disappear.

The Business Decision That Enabled the Technical One

I killed the free tier.

No more local-only users meant no more need for IndexedDB as source of truth. The free tier had been an architectural anchor: it forced local-only mode, which forced IndexedDB-as-truth, which forced delta sync. I was maintaining two products in one codebase, and the free one kept falling behind because backup didn't always work, edge cases weren't tested, and features were Pro-only.

Removing it unwound the whole thing.

The Replacement: Encrypted Entities

The key insight I wish I'd had in September 2025: E2E encryption and server storage are not opposites. You can store data on the server and have zero-knowledge privacy.

The new system:

  • One table, one row per entity, encrypted with XChaCha20-Poly1305
  • Content hash in metadata enables server-side dedup without decrypting
  • Sync = "give me everything newer than version N"
  • That's it

No merge logic, no compaction, no delta log, no conflict resolution. If a device has been offline for a year, it just downloads the current state.

The Numbers

Delta SyncEncrypted Entities
Source code~8,700 lines~2,200 lines
Test code~7,700 linesMuch smaller surface
Delta/entity types36+0 (generic)
Sync time (stale device)1-2 minutesSeconds
Sync crisis workbooks190

What I'd Tell Past Me

Local-first is a storage choice, not a privacy choice. I conflated "the server can't read my data" with "the server can't store my data." E2E encryption gives you zero-knowledge regardless of where data lives.

CRDTs shine in collaborative editing. For single-user multi-device, last-write-wins is usually enough. Google Docs needs CRDTs because two people type in the same paragraph. Your SaaS with one user per account doesn't have that problem.

The free tier was the real problem. One business decision created a cascade of technical complexity. Removing it unwound the whole thing.

Start with the simplest sync that could work. Server stores encrypted current state, client downloads what changed. You can always add CRDTs later if you actually need them.

What I Kept

  • IndexedDB as a cache fast reads, offline-capable UI, instant page loads
  • E2E encryption the server is still zero-knowledge
  • Client-side computation charts and aggregations happen locally

The irony is that the product feels more private now. Sync is invisible, with no spinner and no "review conflicts" modal. Your data is everywhere you need it, encrypted end-to-end, and you never think about sync.

That's what users actually wanted. Not "local-first architecture." They wanted to open the app and see their net worth. Instantly, private and on any device.