A conflict occurs when the same data is modified both locally (while offline) and remotely (by another client or server process). Resolving it correctly — without losing data or confusing users — is one of the harder problems in offline-first design.
Types of Conflicts
| Type | Example |
|---|---|
| Update–Update | User edits a note locally; someone else edits it on the web |
| Delete–Update | User deletes an article; server pushes an update to it |
| Insert–Insert | Two devices create items with the same ID |
| Stale write | Client writes with a version that's no longer the latest |
Strategy 1: Last-Write-Wins (LWW)
The simplest strategy: the newest write by wall clock wins.
data class ArticleEntity(
@PrimaryKey val id: String,
val title: String,
val updatedAt: Long // epoch milliseconds
)
fun merge(local: ArticleEntity, remote: ArticleEntity): ArticleEntity =
if (remote.updatedAt >= local.updatedAt) remote else local
Problems: Wall clocks are unreliable across devices (clock skew, timezone issues). Use server-assigned updatedAt whenever possible.
When to use: Single-user data (user's own settings, notes, preferences).
Strategy 2: Server-Wins
Always accept the server's version. Local changes are discarded if they conflict.
suspend fun syncArticle(id: String) {
val serverVersion = api.getArticle(id)
dao.insert(serverVersion.toEntity()) // replace local unconditionally
}
When to use: Content that the server owns (editorial content, system configuration, admin-created data).
Strategy 3: Client-Wins (with optimistic locking)
Client pushes local changes; server rejects if there's a version mismatch (optimistic locking):
// Client sends current version with the update
suspend fun updateArticle(article: Article) {
try {
val updated = api.updateArticle(
id = article.id,
body = article.body,
version = article.version // must match server's current version
)
dao.insert(updated.toEntity())
} catch (e: ConflictException) { // HTTP 409
// Server rejected: fetch latest and let user decide
val serverVersion = api.getArticle(article.id)
showConflictDialog(local = article, remote = serverVersion)
}
}
Server-side pseudo-code:
UPDATE articles SET body=?, version=version+1
WHERE id=? AND version=? -- optimistic lock check
Strategy 4: Three-Way Merge
Preserve the common ancestor and merge both change sets:
data class Conflict<T>(
val base: T, // last known common ancestor
val local: T, // client's version
val remote: T // server's version
)
fun mergeArticle(conflict: Conflict<Article>): Article {
val (base, local, remote) = conflict
return Article(
id = local.id,
// Field-level merge: take the changed field from each version
title = if (remote.title != base.title) remote.title else local.title,
body = if (remote.body != base.body) remote.body else local.body,
// If both changed the same field — we have a true conflict
// Surface it to the user or apply domain-specific logic
)
}
When to use: Collaborative editing (shared notes, documents). Complex to implement; consider an CRDT library for complex cases.
Strategy 5: User-Resolved Conflicts
When automatic merge isn't safe, show the user both versions:
sealed class SyncResult {
data class Success(val article: Article) : SyncResult()
data class Conflict(val local: Article, val remote: Article) : SyncResult()
}
// In ViewModel:
fun resolveConflict(choice: ConflictChoice) {
when (choice) {
ConflictChoice.KEEP_LOCAL -> repository.pushLocalVersion()
ConflictChoice.KEEP_REMOTE -> repository.acceptServerVersion()
ConflictChoice.MERGE -> repository.mergeAndPush()
}
}
When to use: As a fallback when automatic resolution isn't certain, or for high-stakes data (financial records, health data).
Tracking Conflict State
@Entity
data class ArticleEntity(
@PrimaryKey val id: String,
val title: String,
val syncState: SyncState = SyncState.SYNCED,
val conflictPayload: String? = null // JSON of the conflicting remote version
)
enum class SyncState { SYNCED, PENDING, CONFLICT, FAILED }
Key Takeaways
| Strategy | Complexity | Best for |
|---|---|---|
| Last-write-wins | Low | Single-user data with reliable server timestamps |
| Server-wins | Low | Server-owned content |
| Optimistic locking | Medium | Shared data, low contention |
| Three-way merge | High | Collaborative text editing |
| User-resolved | Medium | High-stakes or irreplaceable data |