androidengineers.Book a session

Data Synchronization

Conflict Resolution Strategies

article25 minHard

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

TypeExample
Update–UpdateUser edits a note locally; someone else edits it on the web
Delete–UpdateUser deletes an article; server pushes an update to it
Insert–InsertTwo devices create items with the same ID
Stale writeClient 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

StrategyComplexityBest for
Last-write-winsLowSingle-user data with reliable server timestamps
Server-winsLowServer-owned content
Optimistic lockingMediumShared data, low contention
Three-way mergeHighCollaborative text editing
User-resolvedMediumHigh-stakes or irreplaceable data

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Conflict Resolution Strategies | Android System Design | Android Engineers