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
| Strategy | Best for | Trade-off |
|---|---|---|
| Dual timestamps | Message ordering | Requires server-assigned timestamp |
| Idempotency keys | Retry safety | Must generate UUID client-side |
| Last-Write-Wins | Simple state updates | Might lose data if two edits collide |
| OT | Collaborative text editing | Complex to implement correctly |
| CRDT | Presence, seen-by, counters | Eventually consistent; no conflicts |
| Sync queue | Offline message send | Need to handle 409 Conflict on retry |