Full-stack · 2026

Tether

Offline-first notes and tasks with hand-written CRDT multi-device sync

Complete · source available

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
fig. 01 — Tether architecture

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 prev observation 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.