androidengineers.Book a session

Designing a Messaging App

Device Sync & Conflict Handling

article20 minHard

When a user's messages are edited from multiple devices simultaneously, or when the device is offline and then reconnects, the client must reconcile local state with server state. Getting this wrong leads to lost messages or duplicate content.

The Core Problem

Timeline:
  Device A (online):   sends msg at T=10
  Device B (offline):  sends msg at T=12 (local time)
  Server receives:     Device A's message first (server T=11)
                       Device B reconnects at T=20; server receives msg
  
  What should the thread look like?
  - Device A's message came first by server time
  - Device B's message used a local timestamp
  → Need: server timestamp for ordering + local timestamp for display

Dual Timestamp Strategy

data class Message(
    val id: String,
    val conversationId: String,
    val senderId: String,
    val text: String,
    val localTimestamp: Long,   // device clock when user tapped Send
    val serverTimestamp: Long?, // assigned by server; null until acknowledged
    val status: MessageStatus
)

// Sort by server timestamp if available; fall back to local
fun List<Message>.sortedForDisplay(): List<Message> {
    return sortedWith(compareBy(
        { it.serverTimestamp ?: Long.MAX_VALUE },  // server time first
        { it.localTimestamp }                       // tiebreak with local
    ))
}

Idempotency Keys: Preventing Duplicates

class MessageSender(private val api: MessageApi, private val dao: MessageDao) {

    suspend fun send(message: Message): Result<Message> {
        // The message.id is the idempotency key — generated client-side (UUID)
        // If the request is retried, the server returns the same result
        return try {
            val serverMessage = api.sendMessage(
                conversationId = message.conversationId,
                text = message.text,
                idempotencyKey = message.id   // UUID prevents duplicate sends
            )

            // Update local record with server timestamp and SENT status
            val confirmed = message.copy(
                serverTimestamp = serverMessage.serverTimestamp,
                status = MessageStatus.SENT
            )
            dao.update(confirmed)
            Result.success(confirmed)
        } catch (e: IOException) {
            // Keep as PENDING; WorkManager will retry
            Result.failure(e)
        }
    }
}

Conflict Resolution Strategies

Last-Write-Wins (LWW)

// Simplest: server timestamp determines winner
// Good for: message sends (don't edit in place), most status updates
fun resolveConflict(local: Message, server: Message): Message {
    return if ((server.serverTimestamp ?: 0) >= (local.serverTimestamp ?: 0)) {
        server
    } else {
        local
    }
}

Operational Transform (for collaborative editing)

// Each edit is an operation: Insert(position, text) or Delete(position, length)
// Client sends: operation + base version number
// Server applies transform if another edit arrived first

data class TextOperation(
    val type: OperationType,
    val position: Int,
    val text: String?,
    val length: Int?,
    val baseVersion: Int
)

// Client-side: optimistic apply → server may return transformed version
fun applyOperation(current: String, op: TextOperation): String {
    return when (op.type) {
        OperationType.INSERT -> current.substring(0, op.position) +
                op.text!! +
                current.substring(op.position)
        OperationType.DELETE -> current.removeRange(op.position, op.position + (op.length ?: 0))
    }
}

CRDT (Conflict-free Replicated Data Type) for Presence

// For "who is typing" or "seen by" — use a Last-Write-Wins register
// Each device's update carries a vector clock; no conflict possible
data class TypingState(
    val userId: String,
    val isTyping: Boolean,
    val lamportTimestamp: Long  // logical clock; always increasing
)

class TypingPresenceMap {
    private val states = mutableMapOf<String, TypingState>()

    fun merge(incoming: TypingState) {
        val existing = states[incoming.userId]
        if (existing == null || incoming.lamportTimestamp > existing.lamportTimestamp) {
            states[incoming.userId] = incoming
        }
        // Otherwise ignore — existing is more recent
    }

    fun getCurrentlyTyping(): List<String> =
        states.values.filter { it.isTyping }.map { it.userId }
}

Sync Queue for Offline Messages

class OfflineSyncManager(private val dao: MessageDao, private val api: MessageApi) {

    suspend fun syncPendingMessages() {
        val pending = dao.getPendingMessages()  // status = PENDING
        
        pending.forEach { message ->
            try {
                val result = api.sendMessage(
                    conversationId = message.conversationId,
                    text = message.text,
                    idempotencyKey = message.id
                )
                dao.update(message.copy(
                    serverTimestamp = result.serverTimestamp,
                    status = MessageStatus.SENT
                ))
            } catch (e: Exception) {
                if (e is HttpException && e.code() == 409) {
                    // Conflict: server already has this message (duplicate send)
                    // Mark as sent, not failed
                    dao.update(message.copy(status = MessageStatus.SENT))
                }
                // Other errors: leave as PENDING for next retry
            }
        }
    }
}

Key Takeaways

StrategyBest forTrade-off
Dual timestampsMessage orderingRequires server-assigned timestamp
Idempotency keysRetry safetyMust generate UUID client-side
Last-Write-WinsSimple state updatesMight lose data if two edits collide
OTCollaborative text editingComplex to implement correctly
CRDTPresence, seen-by, countersEventually consistent; no conflicts
Sync queueOffline message sendNeed to handle 409 Conflict on retry

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Device Sync & Conflict Handling | Android System Design | Android Engineers