CRDTs for mobile sync: Automerge vs Yjs vs cr-sqlite
Meta description: Compare Automerge, Yjs, and cr-sqlite for offline-first mobile sync. Benchmarks, merge semantics, and architecture patterns that eliminate conflict dialogs.
Tags: mobile, architecture, kotlin, multiplatform, backend
TL;DR
CRDTs let mobile apps sync offline edits without conflict resolution dialogs. Automerge has the richest document model, Yjs has the lowest memory footprint, and cr-sqlite fits teams already invested in SQLite. The right choice depends on your data shape, your target device floor, and whether you need document-granularity or row-granularity merging. I’ll walk through what the trade-offs actually look like in production.
The problem: “which version do you want to keep?”
Few UX failures frustrate users more than a conflict resolution dialog. I’ve watched testers stare at one for thirty seconds before picking randomly. Traditional server-authoritative sync forces a choice: last-write-wins (silent data loss) or manual merge (cognitive overhead). CRDTs (Conflict-free Replicated Data Types) eliminate this by making concurrent edits mathematically mergeable.
The question for mobile teams in 2026 isn’t whether to adopt local-first sync. It’s which CRDT implementation fits your constraints.
CRDT primer for mobile engineers
A CRDT guarantees that any two replicas receiving the same set of operations converge to the same state, regardless of operation order. Two families matter:
- State-based (CvRDTs): Ship full state snapshots. Simple but bandwidth-heavy.
- Operation-based (CmRDTs): Ship individual ops. Compact on the wire but require causal delivery.
Modern libraries use hybrid approaches. Automerge and Yjs both encode operations into compact binary formats and reconstruct state locally.
Head-to-head comparison
| Dimension | Automerge 2.x | Yjs | cr-sqlite |
|---|---|---|---|
| Data model | Rich JSON documents (maps, lists, text) | Shared types (YMap, YArray, YText) | SQLite tables with CRDT columns |
| Merge granularity | Per-character / per-field | Per-character / per-field | Per-row / per-column |
| Binary encoding | Columnar compression | Custom lib0 encoding | SQLite + op log |
| Language support | Rust core, WASM, JS, Swift, Kotlin (via FFI) | JS-native, Rust port (y-crdt) | C core, bindings for most platforms |
| Sync protocol | Bloom-filter-based sync | State vectors + update diff | CRR (Convergent Replicated Relations) |
| Typical doc size overhead | ~1.5-2.5x raw JSON | ~1.1-1.5x raw JSON | Minimal over base SQLite |
| Best fit | Collaborative documents, nested structures | Real-time text, lightweight state | Apps already on SQLite, relational data |
The numbers tell a clear story. Yjs’s lib0 variable-length integer format keeps sync payloads tight. Automerge 2.x closed the gap with its columnar storage rewrite, but Yjs still leads on raw encoding efficiency.
Causal ordering with hybrid logical clocks
All three libraries need to answer: which edit happened “before” which? They rely on Hybrid Logical Clocks (HLCs), a combination of wall-clock time and a logical counter that preserves causal ordering without requiring synchronized clocks.
data class HLC(
val wallTime: Long, // millis since epoch
val counter: Int, // logical ticks
val nodeId: String // unique device identifier
) : Comparable<HLC> {
override fun compareTo(other: HLC): Int =
compareValuesBy(this, other, { it.wallTime }, { it.counter }, { it.nodeId })
}
When two devices edit the same field concurrently, the HLC timestamps are incomparable (neither causally precedes the other). The CRDT’s merge function kicks in, typically a deterministic tiebreaker like highest nodeId wins. The point: users never see a dialog because the merge is automatic and deterministic across all replicas.
Wire protocol and sync payload overhead
Most teams get this wrong: they benchmark CRDT size at rest but ignore sync cost. The sync architecture matters more than you’d think.
- Yjs uses a state vector, a map of
{clientId: clock}pairs. A sync round-trip sends your state vector, the peer computes the diff, and returns only missing updates. Typical sync payload for a 10KB document with 50 pending ops: ~2-4 KB. - Automerge uses Bloom filters to identify missing changes. Slightly higher overhead per round-trip but fewer rounds needed for large change sets.
- cr-sqlite piggybacks on SQLite’s existing replication patterns. The CRR extension tracks a per-row version clock and ships row-level diffs.
For mobile, payload size directly impacts battery and data budget. On metered connections, Yjs’s state-vector approach typically produces the smallest sync payloads for frequent, incremental syncs.
Memory and CPU cost on mobile devices
The real constraint on mobile isn’t network. It’s memory. CRDTs carry operation history, and that history grows.
| Scenario (10K ops on a document) | Automerge 2.x | Yjs | cr-sqlite |
|---|---|---|---|
| Heap memory | ~8-12 MB | ~3-5 MB | ~2-4 MB (SQLite managed) |
| Load time (cold) | ~120-200 ms | ~40-80 ms | ~20-50 ms |
| GC pressure | Medium (Rust/WASM helps) | Low (typed arrays) | Minimal (native C) |
cr-sqlite wins on memory because SQLite manages its own page cache outside the JS/Kotlin heap. Yjs’s typed-array internals keep GC pressure low. Automerge’s Rust-compiled WASM core avoids GC entirely on web, but FFI overhead on native mobile adds latency.
For devices at the floor (Android Go phones with 2 GB RAM) cr-sqlite or Yjs are safer choices. Automerge is the better pick when your data model is deeply nested and document-shaped.
What to do with this
Match the library to your data shape. Relational data with foreign keys points to cr-sqlite. Rich documents with nested maps and lists point to Automerge. Lightweight collaborative state points to Yjs. Forcing relational data into a document CRDT (or vice versa) creates pain you don’t need.
Budget for operation history growth. CRDTs trade storage for conflict freedom. Implement compaction strategies: Automerge’s clone() to strip history, Yjs’s encodeStateAsUpdate snapshots, cr-sqlite’s version pruning. Without compaction, memory on low-end devices will degrade over weeks of use.
Prototype your sync loop before committing. Stand up a minimal sync server (a WebSocket relay is sufficient), simulate 48 hours of offline edits on two devices, and measure actual payload sizes and merge correctness on your real data model. The benchmarks above are directional; your mileage depends on edit patterns and document structure.
Local-first has matured. With CRDT libraries available across Kotlin, Swift, and TypeScript, the engineering cost of eliminating conflict dialogs has dropped below the cost of building and maintaining a custom server-authoritative sync layer. I’d make the switch on any new project that handles user-generated content offline.