Overview
- What: an offline-first notes and tasks app where every edit is local and immediate, and devices reconcile later without overwriting concurrent edits.
- Hardest problem: merging concurrent edits with no CRDT library: a multi-value register, an RGA sequence for note text, an operation log and version vectors.
- How it is validated: convergence proofs and a seeded fuzz suite of 530 randomised schedules across 2, 3 and 5 replicas.
The CRDTs and sync protocol are about 1,600 lines of TypeScript against about 1,000 lines of tests. A relay that never inspects operations carries sync between browser replicas, with SQLite persistence.
Architecture
replica A (local edits, op log) -- replica B (local edits, op log) ---+--> relay: opaque (replica, seq) blobs --> SQLite
replica C (local edits, op log) --/ one round trip per sync
each replica: Lamport clock (ordering) + version vector (sync, dedupe)
MV register per field, RGA for note text, observed-remove deletes Highlights
- Precise "no last-write-wins data loss" semantics: per-field registers keep concurrent edits to different fields; same-field conflicts keep both values and surface them; note bodies merge per character via RGA.
- Two clocks with different jobs: Lamport timestamps for ordering and tie-breaks, version vectors for sync and deduplication; exactly-once, causally-ordered delivery is enforced (pending buffer, no-gap vectors), not assumed.
- Observed-remove deletion with
prevobservation ids, so conflict detection needs no clock agreement; restore reliably beats earlier deletes. - The relay stores opaque
(replica, seq)blobs and can be replaced by anything that lists blobs; one round trip per sync. - Measured: 157 bytes per operation on the wire; one keystroke on a 3,002-op log uploads 1 op / 152 bytes; replaying 3,003 ops takes 60 ms; typing cost 0.037 ms per character at 1,000 chars.
- 30 tests including convergence proofs and a seeded fuzz suite of 530 randomised schedules across 2/3/5 replicas; three real bugs documented.
What I learned
Delivery guarantees had to be enforced with a pending buffer and no-gap version vectors rather than assumed. Seeded fuzzing over randomised schedules found problems that hand-picked scenarios did not; three real bugs are documented in the repo.
Limits
No tombstone garbage collection, a linear RGA scan, and no encryption.