CRDTs for Offline-First Mobile Sync: Automerge in Kotlin Multiplatform, Vector Clocks on Constrained Devices, and the Conflict-Free Data Layer That Eliminates Your Backend Sync Service
TL;DR
Conflict-Free Replicated Data Types (CRDTs) let every device merge state independently, with no server-side conflict resolution. By implementing LWW-Registers, G-Counters, and RGA sequences in Kotlin Multiplatform shared code, you get a single conflict-free data layer that works on Android, iOS, and desktop. State-based CRDTs trade larger payloads for simpler infrastructure; operation-based CRDTs invert that trade-off. In my experience building production systems with offline-first requirements, the right CRDT choice eliminates an entire backend sync service and the engineering overhead that comes with it.
The problem with custom sync servers
Most teams get offline sync wrong the same way: they build a centralized arbiter. A server that receives conflicting writes from multiple devices, applies “last write wins” at the field level, and hopes for the best. This creates a single point of failure and requires always-on connectivity for correctness guarantees. Worse, it produces an ever-growing surface area of edge-case conflict logic that nobody wants to maintain.
CRDTs flip the model. Every replica converges to the same state given the same set of updates. Mathematically guaranteed, no coordination required.
CRDT primitives you actually need on mobile
Not every CRDT is practical on constrained devices. These are the ones that matter for typical mobile state:
| Primitive | Use case | Merge cost | Payload overhead |
|---|---|---|---|
| LWW-Register | User profile fields, settings | O(1) | Minimal: timestamp + value |
| G-Counter | Analytics events, view counts | O(n) where n = replicas | Grows linearly with replica count |
| PN-Counter | Inventory, cart quantities | O(n) | 2x G-Counter |
| RGA (Replicated Growable Array) | Collaborative text, ordered lists | O(log n) amortized | Tombstones accumulate over time |
| OR-Set (Observed-Remove Set) | Tags, favorites, selections | O(n) per element | Causal metadata per item |
For most mobile apps, LWW-Registers and OR-Sets cover the majority of sync needs. RGA is only necessary when you need ordered, collaborative sequences.
Implementing in KMP shared code
Put your CRDT logic in commonMain. One implementation, every platform. A minimal LWW-Register in shared Kotlin:
data class LWWRegister<T>(
val value: T,
val timestamp: Long,
val nodeId: String
) {
fun merge(other: LWWRegister<T>): LWWRegister<T> = when {
other.timestamp > this.timestamp -> other
other.timestamp < this.timestamp -> this
else -> if (other.nodeId > this.nodeId) other else this
}
}
The nodeId tiebreaker matters more than people realize. Without it, identical timestamps produce non-deterministic merges, which violates the convergence guarantee. Most tutorials skip this detail.
For richer data structures, Automerge provides a well-proven CRDT engine. Integrating it into KMP means wrapping the native library via expect/actual declarations. Automerge’s core is Rust-based, so you target JNI on Android and C interop on iOS through the shared boundary.
State-based vs operation-based: the mobile trade-off
| Dimension | State-based (CvRDT) | Operation-based (CmRDT) |
|---|---|---|
| Network requirement | Unreliable (idempotent merge) | Exactly-once delivery needed |
| Payload size | Full state on each sync | Individual operations |
| Infrastructure complexity | Lower: just exchange states | Higher: needs causal ordering layer |
| Bandwidth on constrained networks | Higher per message | Lower per message |
| Implementation difficulty | Simpler merge functions | Requires operation log + delivery guarantees |
On mobile, state-based CRDTs are the pragmatic default. You already have unreliable networks. 3G connections drop mid-sync. Apps get backgrounded and sockets die. Requiring exactly-once delivery for operation-based CRDTs means building or adopting a reliable causal broadcast layer, which reintroduces the backend complexity you were trying to escape.
State-based payloads grow with state size, but for typical mobile data sets (user preferences, local lists, document fragments) they stay well within acceptable bounds. If your documents grow large, delta-state CRDTs offer a hybrid approach: you transmit only the state diff since last sync, reclaiming the bandwidth advantage without sacrificing idempotency.
Vector clocks on constrained devices
Vector clocks track causal ordering across replicas. On mobile, the constraint is friendlier than it sounds: most users have a bounded number of devices. A vector clock with entries for a phone, tablet, and laptop is three integers. That’s nothing. This is not a distributed system with thousands of nodes.
Prune entries for devices that have been inactive beyond a threshold, and the metadata stays compact.
Store vector clocks alongside each CRDT in your local database (SQLite via SQLDelight in KMP is the natural fit), and compare them during sync to detect concurrent updates versus causal successors.
The architecture that removes your sync service
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Android │ │ iOS │ │ Desktop │
│ Device │ │ Device │ │ Device │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
│ CRDT State Blobs (opaque) │
└────────┬───────┴───────┬────────┘
▼ ▼
┌───────────────────────┐
│ Dumb Storage Relay │
│ (S3 / Cloud Storage) │
│ No conflict logic │
└───────────────────────┘
Your “backend” becomes a storage relay. It holds opaque CRDT state blobs. It knows nothing about your data model, resolves zero conflicts, and scales with commodity object storage pricing. The implication is real: you delete the sync service, its tests, its deployment pipeline, and its on-call rotation.
Where to start
Start with LWW-Registers and OR-Sets in commonMain. These two primitives cover user settings, favorites, tags, and most entity-level sync needs. Implement them in shared Kotlin and write platform-agnostic property tests that verify convergence.
Default to state-based CRDTs on mobile. The idempotent merge model tolerates unreliable networks without additional infrastructure. Reach for delta-state variants only when payload sizes become a measured problem, not a theoretical one.
Once your clients can independently merge state, your backend doesn’t need conflict resolution logic anymore. Reduce it to authenticated blob storage and spend that engineering time on product work instead.