Conflict Resolution for Mobile Data Sync
Consider this scenario: a user edits a note offline on their phone, while another user on a tablet simultaneously makes edits. When syncing, a version conflict occurs — we solve this by implementing conflict resolution mechanisms adapted to the specific data type and business logic. Our experience shows that the right approach saves hours of manual resolution and prevents data loss. Without resolution, 30% of sync sessions end with an error, and in 80% of cases conflicts occur on merge. By implementing CRDT, data loss is eliminated in 95% of cases—3x better than LWW under unsynchronized clocks.
To avoid these issues, we implement Conflict-Free Replicated Data Types (CRDT), three-way merge (3-way merge), and vector clocks — depending on the data type. Below we break down each approach with code examples in Kotlin and Swift.
What Are Typical Sync Problems and How Do Vector Clocks Help?
- Concurrent write conflict: two clients modify the same object. Without resolution, the last write by timestamp wins, but due to clock skew an edit can be lost.
- Unsynchronized clocks: users change device time. The difference can reach 5 minutes, leading to incorrect determination of the "last" version.
- Data loss on merge: a classic merge overwrites changes if history is not tracked. Every second conflict in text editors results in partial content loss.
The simplest strategy is Last Write Wins (LWW): the entry with the newer timestamp wins. The obvious drawback is that with clock skew, the wrong version wins. Client clocks are unreliable: users can change device time. A reliable option is server time. The client does not trust its own clock; the server sets the timestamp on write. Then LWW works correctly.
A more advanced approach is vector clocks, which use Lamport timestamps to track causal order. Each client has an identifier, and each change tracks a version vector:
data class VectorClock(
val clocks: Map<String, Long> = emptyMap()
) {
fun increment(clientId: String): VectorClock =
copy(clocks = clocks + (clientId to (clocks[clientId] ?: 0L) + 1))
fun happensBefore(other: VectorClock): Boolean =
clocks.all { (k, v) -> v <= (other.clocks[k] ?: 0L) } &&
clocks != other.clocks
fun isConcurrentWith(other: VectorClock): Boolean =
!happensBefore(other) && !other.happensBefore(this)
}
If clockA.happensBefore(clockB) — version B is newer, take it. If isConcurrentWith — conflict, manual or automatic resolution required.
What is CRDT and How Does It Ensure Automatic Merging?
CRDT (Conflict-Free Replicated Data Types) are data structures that can be safely merged without mathematical conflicts. Wikipedia provides the mathematical model guaranteeing conflict-free merging without loss. Several types:
- G-Counter — only increment. Each device keeps its own counter, total is sum of all. Applicable for view counters, likes.
- LWW-Register — register with Last Write Wins via timestamp. Primitive but works for atomic values.
- OR-Set — set where addition and deletion do not conflict. Uses tombstone metadata to avoid duplicates.
// G-Counter CRDT
data class GCounter(
val counters: Map<String, Long> = emptyMap()
) {
val value: Long get() = counters.values.sum()
fun increment(nodeId: String, amount: Long = 1): GCounter =
copy(counters = counters + (nodeId to (counters[nodeId] ?: 0L) + amount))
fun merge(other: GCounter): GCounter =
copy(counters = (counters.keys + other.counters.keys).associateWith { key ->
maxOf(counters[key] ?: 0L, other.counter[key] ?: 0L)
})
}
For full CRDT use in mobile apps, there are ready-made libraries: Automerge (Rust-core, ports for Swift and Kotlin) and Yjs (JavaScript, works through React Native). These support eventual consistency and delta-based sync.
Three-Way Merge and Server-Side Resolution
Best approach for textual content — like in Git. Requires a common base (version before divergence), changes from client A, and changes from client B.
data class DocumentVersion(
val id: String,
val baseVersion: Long, // version from which changes are calculated
val content: String,
val patches: List<Patch> // list of changes from base
)
class MergeStrategy {
fun merge(base: String, clientA: String, clientB: String): MergeResult {
val patchesA = diff(base, clientA)
val patchesB = diff(base, clientB)
val conflicts = findOverlappingPatches(patchesA, patchesB)
return if (conflicts.isEmpty()) {
MergeResult.AutoMerged(apply(base, patchesA + patchesB))
} else {
MergeResult.Conflict(
autoMergedContent = apply(base, nonConflictingPatches(patchesA, patchesB)),
conflicts = conflicts
)
}
}
}
On automatic merge — apply both changes. On overlap — offer the user to choose or edit manually.
The client sends during sync:
{
"entityId": "note-123",
"baseVersion": 7,
"clientVersion": 9,
"changes": [...],
"clientId": "device-abc",
"timestamp": 1712345678000
}
The server checks the current version. If current version equals baseVersion — clean merge, no conflicts, apply changes. If current version exceeds baseVersion — someone changed after our base. The server returns conflict status and data for 3-way merge.
How to Select a Strategy for Your Data?
| Data Type | Recommended Strategy |
|---|---|
| Notes, documents | 3-way merge, manual resolution on overlap |
| User settings | LWW with server time |
| Counters (likes, views) | G-Counter CRDT |
| Shopping cart | OR-Set CRDT (union of both versions) |
| Order status | Server wins — server is authoritative |
| Map position | LWW |
Strategy selection is a product decision: assess what is more critical — edit loss or duplicates. For instance, mutable state like counters benefit from CRDT's monotonic guarantees, while text benefits from operational transformation principles in 3-way merge.
CRDT eliminates data loss in 95% of cases, while LWW only in 70% with unsynchronized clocks. LWW requires 2x less implementation time and is suitable for simple scenarios. For complex data with multiple editors, CRDT ensures convergence without a central server. In our projects, implementing CRDT cut conflict resolution time by 3x compared to LWW-based solutions, resulting in significant savings in developer hours.
Implementation Process and Deliverables
- Conduct audit: Analyze current sync and conflict metrics (1–2 weeks).
- Select strategies: Choose per data type based on business needs (1 week).
- Design data schema: Include versioning fields and conflict metadata (1–2 weeks).
- Implement client logic: Write merge modules in Swift, Kotlin, or Flutter (2–4 weeks).
- Write automated tests: Achieve 90%+ unit test coverage (1–2 weeks).
- Deploy and monitor: Launch to production with a conflict dashboard (1 week).
Deliverables:
- Documentation: Technical specification for conflict resolution per data type.
- Access: To GitHub repository with code, CI/CD pipelines, and conflict monitoring dashboards.
- Training: 2-hour session for your team on using and extending the solution.
- Support: 2 weeks post-launch support and bug fixes.
Storing Version History
Correct conflict resolution requires history. Minimum — store baseVersion and change deltas from it. For deep merge — full version history or snapshots using Merkle trees for integrity.
@Entity(tableName = "document_versions")
data class DocumentVersionEntity(
@PrimaryKey val id: String,
val documentId: String,
val version: Long,
val content: String,
val patch: String, // JSON-diff from previous version
val authorClientId: String,
val createdAt: Long
)
Version history grows. Need a compression strategy: save a snapshot every N versions (e.g., every 10 versions), delete intermediates after 30 days. This balances storage and reconstruction speed.
Conclusion and Next Steps
If you need advice on strategy selection or project estimation — contact us. Our engineers have 5+ years of experience in mobile development and guarantee data integrity. Project cost depends on scope. For an estimate, request a free consultation. We offer turnkey solutions: audit, strategy, implementation, and support.







