I spent three months adding offline sync to ctrodb — a client-side database I'd been working on for about a year before that. The database itself w...
For further actions, you may consider blocking this person and/or reporting abuse
The change log written in the same transaction as the data write is the bit most people skip and then regret. Orphaned changes are how you get a "synced" database that's quietly lying to you. Conflict resolution is where it gets expensive though. Last-write-wins is fine until two devices edit the same record and the first edit disappears with no signal.
We hit the same wall building moteDB, a Rust embedded multimodal store. The write-plus-changelog atomicity is the same pattern, except on embedded and edge devices the "server" is usually another local process or a peer, not a cloud. Curious how ctrodb behaves when the sync target is unreachable for days instead of seconds. Did you ever measure change-log growth on a long-lived offline client, or is compaction something you pushed down the road?
The write-plus-changelog atomicity turned out to be one of those things that looks obvious in hindsight but is easy to miss in a first pass, glad it came through clearly.
On compaction:
compactSyncQueuededuplicates superseded pending/failed changes for the same record, but committed entries accumulate untilclearCommittedSyncruns (auto-triggered after a successful push). Haven't stress-tested it with weeks of offline use yet, that's on the near-term test list. If growth becomes a problem on real devices, I'll likely add a max-age TTL and a periodic vacuum before shipping the production sync docs.Still actively testing the offline durability story. Appreciate the signal on what matters at the embedded/edge scale, that's a whole different class of constraints.
Thanks for the detailed walkthrough of the compaction pipeline. The committed-vs-pending split is clean. One thing we hit with moteDB's WAL on edge devices: if you TTL-vacuum entries before a long-delayed sync, there's no merge base left to reconstruct from. A minimum retained batch count as a safety floor solved it. Wondering if you're considering something similar alongside the age-based TTL, or if the auto-trigger after successful push is strict enough that you don't need it.
“Sync isn’t about moving bytes” is the sentence that usually separates the demo from the real system. The first overwrite-on-two-devices moment is where offline-first work stops being a transport problem and becomes a history/conflict problem, and your writeup frames that transition clearly. I also appreciate that you highlighted how much more code sync demanded than the base database, because teams routinely underestimate that multiplier. For browser-local systems, the quality of change tracking and merge semantics matters more than the storage layer itself once real users start editing in parallel. Curious what finally made the third conflict resolver feel correct enough to keep instead of rewrite again?
Thanks, appreciate the close read. The third resolver stuck because I stopped trying to handle every case upfront and made the engine composable instead. The first two were attempts at a universal strategy (field-level auto-merge first, then a CRDT-lite thing) and both collapsed under their own complexity.
The keeper is the "custom" strategy hook. The engine tracks conflicting fields and surfaces both versions, but what actually happens is the caller's decision. That made the resolver itself trivial, the hard part is just plumbing the data correctly so the app can decide. Once I accepted that the library doesn't know the business semantics of a record, the whole thing got simpler.
Still iterating on it. More strategies and better dev tooling for debugging conflicts are in progress.
Really like that you led with the failure cases instead of a polished "look what I built." The one I would push to the top of the list, and it is the exact bug hiding behind "tested with simulated networks first," is idempotency on push. Your syncing -> pending revert on network failure is correct except for the single ordering that actually matters: the server commits the batch and THEN the response gets lost. Client sees a failure, reverts to pending, re-pushes the same ops, and now the change is applied twice server-side with nothing on the client aware of it. A client-generated change id per op that the server dedups on turns the replay into a no-op. W/o it, retries are only safe as long as the network fails in the polite direction, which real networks don't.
The other one waiting for you is deletes. Once onAfterDelete is just another change-log entry, delete-vs-edit across two offline clients is the nastiest conflict in the whole engine: LWW by timestamp will either resurrect a row someone deleted or silently drop an edit, depending on whose clock ran ahead. Tombstones (the delete syncs as a marker instead of an absence) kill the resurrection, and then you inherit the genuinely hard part, deciding when it is safe to GC a tombstone w/o a client that has been offline for a month re-introducing the row on its next sync. Worth designing before you have delete data in the wild, retrofitting it is brutal. And if you ever want to de-fang the LWW clock-skew problem w/o going full CRDT, a hybrid logical clock is the cheap middle ground: it makes "last" mean last in a skew-resistant order instead of trusting two drifted wall clocks.
This is gold , thanks for writing it up.
The idempotency hole is real and I've already been burned by it in testing. Client-generated change IDs are in the tracker already (every
SyncChangeRecordhas anidfield), but the server-side dedup logic isn't wired yet. That's the next thing on the build list before the sync transport docs go live.On deletes: you're right that tombstones are the honest answer, and tombstone GC is where the real pain lives. The change log already records delete operations as full entries (type, recordId, prevData, timestamp), so the substrate for it is there, what's missing is the server protocol for tombstone acknowledgment before pruning. Haven't shipped it yet. Still designing the ack lifecycle.
HLC is something I've been reading up on but haven't committed to. The current timestamp is
new Date().toISOString()which is obviously naive. LWW is the default for a reason (simplicity), but I want to offer better defaults before calling sync production-ready. Appreciate the nudge.Still building and testing all of this, the posts are the real-time design notes more than a finished manual. Feedback like this directly shapes what gets fixed next.
The ack lifecycle might be cheaper than it looks: you may not need per-tombstone acknowledgment at all. This is the same trick Postgres replication slots use, the WAL only advances past what every consumer has consumed. If each client already syncs from a monotonic cursor over the change log, "acked" falls out for free, a tombstone is prunable once every known client's cursor has passed its sequence. The one real decision left is the policy knob for the client that never comes back: pick a max offline age, and past it the cursor is invalidated and the client does a full resync on return. That single rule GCs tombstones and kills the month-offline row resurrection in the same move, because the stale client never gets to replay its old ops at all.
“Sync is not about moving bytes” is the key lesson.
The hard part is deciding what a conflict means in product language. Record-level last-write-wins is simple until the record represents something users think of as multiple independent facts. Then a title edit, status change, and comment append may each need different merge rules.
I like that you kept a change log. That is the piece teams often skip in the first version, and it becomes painful later. A durable sync log is not just for retries; it is also how you explain to a user why their local edit lost, merged, or needs manual resolution.
Rewriting the conflict resolver three times is the rite of passage that ends at either CRDTs or a version vector, right about when last-write-wins first eats a write under clock skew. The transport you threw out was probably the cheaper lesson of the two.
Conflict resolution in client-side sync is notoriously tricky, especially when dealing with concurrent offline edits without relying on heavy CRDT libraries. I'm curious how you handled the transport layer's backpressure when a client comes back online with a massive queue of local changes. I actually ran into similar offline-first architecture headaches when putting together our SaaS boilerplate, PubliFlow, though we ended up leaning on Supabase's real-time subscriptions to avoid building the sync engine from scratch.