androidengineers.Book a session

Designing a Messaging App

Messaging Models & Ordering

article25 minHard

Building a chat messaging feature requires designing for correct message ordering, delivery state, deduplication, and offline support. The naive approach — send a message and append it to the UI — breaks quickly in real conditions.

Message Data Model

@Entity(tableName = "messages")
data class MessageEntity(
    @PrimaryKey val id: String,           // server-assigned UUID
    val conversationId: String,
    val senderId: String,
    val body: String,
    val status: MessageStatus,
    val createdAt: Long,                  // server timestamp (for ordering)
    val localCreatedAt: Long,             // client timestamp (for optimistic ordering)
    val isOptimistic: Boolean = false,    // true = not yet confirmed by server
    val localId: String? = null           // temp ID for matching optimistic → server message
)

enum class MessageStatus {
    SENDING,    // optimistic — sent to server, waiting for ack
    SENT,       // server received
    DELIVERED,  // delivered to recipient's device
    READ,       // recipient opened
    FAILED      // send failed; can retry
}

Message Ordering: The Problem

Network latency means messages can arrive out of order. You need two ordering keys:

  • createdAt: server timestamp — authoritative for final order
  • localCreatedAt: client timestamp — used for optimistic UI before server ack
@Dao
interface MessageDao {
    // Order by server timestamp for confirmed messages; local timestamp for optimistic
    @Query("""
        SELECT * FROM messages 
        WHERE conversation_id = :conversationId 
        ORDER BY 
            CASE WHEN is_optimistic = 1 THEN local_created_at ELSE created_at END ASC
    """)
    fun getMessages(conversationId: String): Flow<List<MessageEntity>>
}

Optimistic Message Sending

class MessageRepository(
    private val api: MessagingApi,
    private val dao: MessageDao
) {
    suspend fun sendMessage(conversationId: String, body: String) {
        val localId = UUID.randomUUID().toString()

        // Step 1: Insert optimistic message immediately
        val optimistic = MessageEntity(
            id = localId,
            conversationId = conversationId,
            senderId = currentUserId,
            body = body,
            status = MessageStatus.SENDING,
            createdAt = 0,                          // unknown yet
            localCreatedAt = System.currentTimeMillis(),
            isOptimistic = true,
            localId = localId
        )
        dao.insert(optimistic)

        // Step 2: Send to server
        try {
            val response = api.sendMessage(conversationId, body, localId)

            // Step 3a: Replace optimistic message with server-confirmed version
            dao.delete(localId)
            dao.insert(
                MessageEntity(
                    id = response.messageId,
                    conversationId = conversationId,
                    senderId = currentUserId,
                    body = body,
                    status = MessageStatus.SENT,
                    createdAt = response.createdAt,
                    localCreatedAt = optimistic.localCreatedAt,
                    isOptimistic = false,
                    localId = localId
                )
            )
        } catch (e: IOException) {
            // Step 3b: Mark as failed — allow retry
            dao.updateStatus(localId, MessageStatus.FAILED)
        }
    }

    suspend fun retryMessage(localId: String) {
        val message = dao.getByLocalId(localId) ?: return
        dao.updateStatus(localId, MessageStatus.SENDING)
        sendMessage(message.conversationId, message.body)
    }
}

Deduplication: Idempotency Keys

// Pass localId as idempotency key to the server
// If the same request is retried, server returns the same message ID
data class SendMessageRequest(
    val conversationId: String,
    val body: String,
    val idempotencyKey: String  // = localId; server deduplicates on this
)

This prevents double-send when the network call succeeds but the response is lost.

WebSocket Receiving Order

class MessageSyncManager(private val socket: WebSocket, private val dao: MessageDao) {

    fun startSync(conversationId: String): Job = coroutineScope.launch {
        socket.events
            .filterIsInstance<SocketEvent.NewMessage>()
            .filter { it.conversationId == conversationId }
            .collect { event ->
                val incoming = event.message

                // Dedup: ignore if we already have this message (from optimistic or prior sync)
                if (dao.exists(incoming.id)) return@collect

                // Check if this matches one of our optimistic messages
                val optimisticMatch = incoming.idempotencyKey?.let { dao.getByLocalId(it) }
                if (optimisticMatch != null) {
                    // Replace optimistic with confirmed server message
                    dao.delete(optimisticMatch.id)
                }

                dao.insert(incoming.toEntity())
            }
    }
}

Key Takeaways

ConceptRule
Dual timestampscreatedAt (server) for final ordering; localCreatedAt for optimistic ordering
Optimistic UIInsert with SENDING status immediately; update to SENT on server ack
Failed messagesMark FAILED; show retry UI; don't delete — preserve message content
Idempotency keyPass localId so server can deduplicate retried sends
WebSocket dedupCheck dao.exists(id) before inserting received messages
Order by timestampNever trust insertion order — always order by server timestamp

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Messaging Models & Ordering | Android System Design | Android Engineers