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