Skip to main content

Offline Sync & Conflict Resolution

TL;DR

Local-First: Store all data locally (SQLite, Realm), cache server responses, never force cloud-only.

Conflict Resolution: Last-write-wins (simple, lossy), Operational Transform (complex, rich), CRDTs (powerful, emergent consistency).

Sync Strategies: Queue writes offline, retry on reconnect, merge conflicts, notify user of resolutions.

Eventual Consistency: Accept temporary divergence; guarantee convergence when online.

Learning Objectives

You will be able to:

  • Design local-first data architectures that work offline.
  • Implement reliable sync with retry logic and idempotency.
  • Choose and implement conflict resolution strategies.
  • Handle edge cases (app crash during sync, network flakiness).
  • Test offline scenarios and conflict scenarios.

Motivating Scenario

User edits document offline (title, content). Meanwhile, another user edits same document online. When offline user reconnects:

  • Both have changes
  • Which wins? Title: user A's version, content: user B's version? Or one overwrites the other?
  • User expects both changes merged

Without proper sync: last write wins (other changes lost) or error (sync fails). With proper sync: changes merged intelligently, user notified of any conflicts.

Core Concepts

Local-First Architecture

All data stored locally first, server is backup/sync target:

App → Local Storage (SQLite/Realm)

Network

Server

Works offline (local), syncs when online.

Benefits:

  • Instant reads (no network wait)
  • Works offline automatically
  • Faster perceived performance
  • Network glitches don't break app

Write Queue Pattern

Queue writes locally, replay when online:

WriteQueue.kt
data class WriteOperation(
val id: String,
val type: String, // "create", "update", "delete"
val entityId: String,
val data: Map<String, Any>,
val timestamp: Long,
var synced: Boolean = false,
var error: String? = null,
)

class OfflineSyncManager(val db: Database) {
fun queueWrite(operation: WriteOperation) {
// Store in local queue table
db.insertWrite(operation)

// Try to sync immediately if online
if (isOnline()) {
syncWrites()
}
}

fun syncWrites() {
val writes = db.getPendingWrites()

writes.forEach { write ->
try {
val response = api.sync(write)

// Mark synced
db.updateWrite(write.id, synced = true)

// Update local state with server response
db.updateEntity(write.entityId, response)
} catch (e: Exception) {
write.error = e.message
db.updateWrite(write.id, error = e.message)
// Will retry later
}
}
}
}

Conflict Resolution Strategies

1. Last-Write-Wins (LWW)

Simplest: newer timestamp overwrites older.

Client write @ 10:00
Server write @ 10:05
Result: Server write wins (later timestamp)

Pros: Simple, no complex logic Cons: Data loss (client changes discarded)

LastWriteWins.kt
fun resolveConflict(client: Document, server: Document): Document {
return if (client.timestamp > server.timestamp) client else server
}

2. Operational Transform (OT)

Google Docs approach: transform operations to account for concurrent edits.

Client: Insert "Hello" at position 0
Server: Insert "World" at position 0
Result: Both inserts processed correctly ("HelloWorld" or "WorldHello")

Pros: Preserves both changes Cons: Complex, hard to implement correctly

OperationalTransform.kt
sealed class Operation {
data class Insert(val position: Int, val text: String) : Operation()
data class Delete(val position: Int, val length: Int) : Operation()
}

fun transform(op1: Operation, op2: Operation): Operation {
// Adjust op1 to account for op2's position changes
return when {
op1 is Insert && op2 is Insert ->
if (op1.position <= op2.position)
op1 // Position unchanged
else
Insert(op1.position + op2.text.length, op1.text) // Shift position
// ... handle other combinations
else -> op1
}
}

3. CRDTs (Conflict-free Replicated Data Types)

Structure data so conflicts resolve automatically (no central authority needed).

Pros: Decentralized, mathematically guaranteed convergence Cons: Complex data structures, overhead

CRDTExample.kt
// Last-Writer-Wins Register (CRDT)
data class Register<T>(
val value: T,
val timestamp: Long,
val nodeId: String, // Unique node identifier
)

fun merge(local: Register<String>, remote: Register<String>): Register<String> {
return when {
remote.timestamp > local.timestamp -> remote // Remote newer
remote.timestamp < local.timestamp -> local // Local newer
remote.timestamp == local.timestamp ->
// Tie: use node ID as tiebreaker (total order)
if (remote.nodeId > local.nodeId) remote else local
}
}

// Client-side merge
val local = Register("Hello", 10000, "device-1")
val remote = Register("World", 10000, "device-2")
val merged = merge(local, remote)
// Result: deterministic (same on all devices)

Sync Patterns

Pattern: Exponential Backoff on Sync Failure

Retry failed syncs with increasing delays:

SyncBackoff.kt
var retryCount = 0
val maxRetries = 10
val baseDelay = 1000L // 1 second

fun retrySyncWithBackoff(write: WriteOperation) {
val delay = baseDelay * Math.pow(2.0, retryCount.toDouble()).toLong()

Handler().postDelayed({
try {
api.sync(write)
retryCount = 0 // Reset on success
} catch (e: Exception) {
if (retryCount < maxRetries) {
retryCount++
retrySyncWithBackoff(write)
}
}
}, delay)
}

Pattern: Differential Sync

Only sync changed fields, not entire document:

DifferentialSync.kt
data class Change(
val entityId: String,
val field: String,
val oldValue: Any,
val newValue: Any,
val timestamp: Long,
)

// Client tracks field-level changes
fun trackChange(field: String, oldValue: Any, newValue: Any) {
val change = Change(
entityId = "doc-1",
field = field,
oldValue = oldValue,
newValue = newValue,
timestamp = System.currentTimeMillis()
)
db.insertChange(change)
}

// Sync only changed fields
fun syncChanges() {
val changes = db.getPendingChanges()
api.syncChanges(changes)
}

Pitfalls & Solutions

Pitfall: Sync Lost on App Crash

Problem: App crashes during sync, changes never sent.

Solution: Use write queue with persistence. Mark synced after confirmed.

Pitfall: Duplicate Syncs

Problem: Network retry sends same change twice, creates duplicate.

Solution: Idempotent operations + deduplication (see earlier code).

Pitfall: User Confusion on Conflicts

Problem: User unaware changes were discarded (LWW).

Solution: Notify user explicitly, show conflict resolution UI.

Design Review Checklist

  • Is all data stored locally (SQLite/Realm)?
  • Are writes queued locally before syncing?
  • Is sync operation idempotent (safe to retry)?
  • Is conflict resolution strategy documented?
  • Are edge cases handled (app crash, network flakiness)?
  • Is user notified of sync failures/conflicts?
  • Can app function fully offline?
  • Is sync tested in offline scenarios?
  • Are old/stale writes cleaned up (prevent queue bloat)?
  • Is performance monitored (sync latency, battery impact)?

When to Use / When Not to Use

Use Local-First Sync When:

  • Mobile users on unreliable networks
  • Offline functionality critical
  • Collaborative editing (multiple users)
  • Responsiveness important (instant writes)

Simpler Alternatives If:

  • Web-only, always-online app
  • Stale reads acceptable
  • Data is read-heavy (no offline writes)

Showcase: Sync Strategies

Self-Check

  1. Why use a write queue instead of syncing immediately?
  2. What's the difference between LWW and OT conflict resolution?
  3. How would you prevent duplicate syncs when network retries occur?

Next Steps

One Takeaway

ℹ️

Local-first architecture with write queuing enables seamless offline experiences. Choose conflict resolution based on data type: LWW for simple data, OT/CRDTs for collaborative editing. Test sync failures extensively—network flakiness will happen.

References

  1. Realm Database
  2. SQLite Database
  3. Yjs: Shared Data Types
  4. Automerge: CRDT Library
  5. CRDTs for Shared Editing