AlgoInsight

Free lesson · Mobile System Design Interviews

Chat Application - Deep Dive: Offline Support & Data Storage

Medium9 min readmobilemobile-system-designinterview-prep

Deep Dive #2: When the Network Dies

Offline support is even more critical in a chat app than in a social feed. If you're reading Instagram and the network dies, you just see cached posts. No problem.

But if you're chatting with a friend and your network drops, your message vanishes. They type back. You never see their response. The app feels broken.

So offline support isn't a nice-to-have. It's essential.

The Local Database Schema (Deeper Dive)

We've talked about the data layer in architecture. Now let's see the actual schema, because it's more nuanced than you might think.

The Problem with a Single Messages Table

You might think: "Just one messages table, store everything."

But here's the issue: When a user sends a message, it doesn't have a server-assigned ID yet.

1. User types "Hi" and hits send
2. App creates a local record with no messageId (null)
3. HTTP request is in flight
4. Network goes down (or times out)
5. App needs to retry, but can't overwrite the original record
   (because it doesn't have an ID to look up by)

So we use two tables:

Table 1: Messages (Confirmed)

These are messages that have a server-assigned ID. They're permanent.

Kotlin (Room):

@Entity(
    tableName = "messages",
    indices = [
        Index(value = ["conversationId", "sequence"], name = "idx_conv_seq")
    ]
)
data class MessageEntity(
    @PrimaryKey
    val messageId: String,            // Server-assigned, unique
    val conversationId: String,       // Foreign key
    val sequence: Long,               // Order by this, not timestamp
    val fromUserId: String,
    val toUserId: String,
    val content: String,
    val status: String,               // SENT, DELIVERED, READ
    val createdAt: Long,              // Milliseconds since epoch (approximate)
    val readAt: Long? = null,
    val syncedToServer: Boolean = true
)

@Dao
interface MessageDao {
    @Query("SELECT * FROM messages WHERE conversationId = :convId ORDER BY sequence ASC")
    fun observeMessagesInConversation(convId: String): Flow<List<MessageEntity>>

    @Query("SELECT * FROM messages WHERE messageId = :id")
    suspend fun getMessageById(id: String): MessageEntity?

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertOrUpdate(message: MessageEntity)

    @Query("UPDATE messages SET status = :status WHERE messageId = :id")
    suspend fun updateStatus(id: String, status: String)

    @Query("SELECT MAX(sequence) FROM messages WHERE conversationId = :convId")
    suspend fun getLastSequenceInConversation(convId: String): Long?
}

Swift (Core Data):

import CoreData

@objc(MessageEntity)
public class MessageEntity: NSManagedObject {
    @NSManaged public var messageId: String
    @NSManaged public var conversationId: String
    @NSManaged public var sequence: Int64
    @NSManaged public var fromUserId: String
    @NSManaged public var toUserId: String
    @NSManaged public var content: String
    @NSManaged public var status: String
    @NSManaged public var createdAt: Date
    @NSManaged public var readAt: Date?
    @NSManaged public var syncedToServer: Bool
}

extension MessageEntity {
    static func fetchRequestForConversation(_ convId: String) -> NSFetchRequest<MessageEntity> {
        let request = NSFetchRequest<MessageEntity>(entityName: "MessageEntity")
        request.predicate = NSPredicate(format: "conversationId == %@", convId)
        request.sortDescriptors = [NSSortDescriptor(keyPath: \MessageEntity.sequence, ascending: true)]
        return request
    }

    static func createEntity(in context: NSManagedObjectContext,
                             messageId: String,
                             conversationId: String,
                             sequence: Int64,
                             fromUserId: String,
                             toUserId: String,
                             content: String,
                             status: String = "SENT",
                             createdAt: Date = Date()) -> MessageEntity {
        let entity = NSEntityDescription.insertNewObject(forEntityName: "MessageEntity", into: context) as! MessageEntity
        entity.messageId = messageId
        entity.conversationId = conversationId
        entity.sequence = sequence
        entity.fromUserId = fromUserId
        entity.toUserId = toUserId
        entity.content = content
        entity.status = status
        entity.createdAt = createdAt
        return entity
    }
}

Table 2: PendingMessages (Unconfirmed)

These are messages the user sent, but the server hasn't confirmed yet. They don't have a messageId.

Kotlin (Room):

@Entity(
    tableName = "pending_messages",
    foreignKeys = [
        ForeignKey(
            entity = ConversationEntity::class,
            parentColumns = ["conversationId"],
            childColumns = ["conversationId"],
            onDelete = ForeignKey.CASCADE
        )
    ]
)
data class PendingMessageEntity(
    @PrimaryKey
    val localId: String,              // Generated locally, unique to this device
    val conversationId: String,
    val toUserId: String,
    val content: String,
    val createdAt: Long,
    val retryCount: Int = 0,
    val lastRetryTime: Long? = null,
    val failureReason: String? = null  // For debugging
)

@Dao
interface PendingMessageDao {
    @Query("SELECT * FROM pending_messages WHERE conversationId = :convId ORDER BY createdAt ASC")
    fun observePendingMessages(convId: String): Flow<List<PendingMessageEntity>>

    @Query("SELECT * FROM pending_messages WHERE retryCount < 5 ORDER BY createdAt ASC")
    suspend fun getPendingMessagesNeedingRetry(): List<PendingMessageEntity>

    @Insert
    suspend fun insert(message: PendingMessageEntity)

    @Query("UPDATE pending_messages SET retryCount = retryCount + 1, lastRetryTime = :time WHERE localId = :id")
    suspend fun incrementRetryCount(id: String, time: Long)

    @Query("DELETE FROM pending_messages WHERE localId = :id")
    suspend fun delete(id: String)
}

Swift (Core Data):

@objc(PendingMessageEntity)
public class PendingMessageEntity: NSManagedObject {
    @NSManaged public var localId: String
    @NSManaged public var conversationId: String
    @NSManaged public var toUserId: String
    @NSManaged public var content: String
    @NSManaged public var createdAt: Date
    @NSManaged public var retryCount: Int32
    @NSManaged public var lastRetryTime: Date?
    @NSManaged public var failureReason: String?
}

extension PendingMessageEntity {
    static func getPendingMessagesNeedingRetry(in context: NSManagedObjectContext) -> [PendingMessageEntity] {
        let request = NSFetchRequest<PendingMessageEntity>(entityName: "PendingMessageEntity")
        request.predicate = NSPredicate(format: "retryCount < 5")
        request.sortDescriptors = [NSSortDescriptor(keyPath: \PendingMessageEntity.createdAt, ascending: true)]
        return try! context.fetch(request)
    }
}

Sending Messages Offline

Here's the complete flow when the network is unavailable:

Step-by-Step

1. User types "Hey, are you there?" and hits send
                ↓
2. App generates localId = "alice_msg_1712102400000"
                ↓
3. App creates PendingMessageEntity and saves to database
                ↓
4. ChatViewModel observes database change, updates UI
   (User sees message immediately with spinner: "Sending...")
                ↓
5. App attempts HTTP POST /messages with requestId="alice_msg_1712102400000"
                ↓
6. Network is down → HTTP times out after 30 seconds
                ↓
7. MessagesRepository catches error, increments retryCount on PendingMessageEntity
                ↓
8. Schedules retry using exponential backoff (wait 2s, then retry)
                ↓
9. [2 seconds later] Network comes back
                ↓
10. Retry HTTP POST
                ↓
11. Success! Server responds: {messageId: "msg_abc123", ...}
                ↓
12. MessagesRepository:
    - Creates MessageEntity with the server-assigned ID
    - Deletes the PendingMessageEntity
    - Updates local database
                ↓
13. ChatViewModel observes change, updates UI
    (Spinner disappears, checkmark appears)

Kotlin Implementation

class MessagesRepository(
    private val localDao: MessageDao,
    private val pendingDao: PendingMessageDao,
    private val remoteSource: MessagesRemoteDataSource
) {
    suspend fun sendMessage(conversationId: String, toUserId: String, content: String) {
        // Step 1: Generate local ID
        val localId = generateLocalId()  // "alice_msg_${System.currentTimeMillis()}"

        // Step 2: Create and save pending message
        val pending = PendingMessageEntity(
            localId = localId,
            conversationId = conversationId,
            toUserId = toUserId,
            content = content,
            createdAt = System.currentTimeMillis()
        )
        pendingDao.insert(pending)

        // Step 3: Attempt send in background
        viewModelScope.launch {
            attemptSend(pending)
        }
    }

    private suspend fun attemptSend(pending: PendingMessageEntity, retryCount: Int = 0) {
        try {
            // Step 4: HTTP POST
            val response = remoteSource.sendMessage(
                toUserId = pending.toUserId,
                content = pending.content,
                requestId = pending.localId
            )

            // Step 5: Success! Convert pending to confirmed
            val message = MessageEntity(
                messageId = response.messageId,
                conversationId = pending.conversationId,
                sequence = response.sequence,
                fromUserId = getCurrentUserId(),
                toUserId = pending.toUserId,
                content = pending.content,
                status = "SENT",
                createdAt = System.currentTimeMillis()
            )

            // Save confirmed message
            localDao.insertOrUpdate(message)

            // Delete pending message
            pendingDao.delete(pending.localId)

        } catch (e: Exception) {
            // Step 6: Failure, schedule retry
            if (retryCount < 5) {
                val nextRetryCount = retryCount + 1
                val delayMs = exponentialBackoff(nextRetryCount)

                pendingDao.incrementRetryCount(pending.localId, System.currentTimeMillis())

                // Use a background scheduler (WorkManager, or just coroutine delay)
                launch {
                    delay(delayMs)
                    attemptSend(pending, nextRetryCount)
                }
            } else {
                // Give up after 5 retries
                pendingDao.updateFailureReason(
                    pending.localId,
                    "Failed to send after 5 attempts: ${e.message}"
                )
            }
        }
    }

    private fun exponentialBackoff(retryCount: Int): Long {
        // 2s, 4s, 8s, 16s, 32s
        val baseDelay = 2000L
        return baseDelay * (1L shl (retryCount - 1))
    }
}

Swift Implementation

class MessagesRepository: ObservableObject {
    let localContext: NSManagedObjectContext
    let remoteSource: MessagesRemoteDataSource

    func sendMessage(
        conversationId: String,
        toUserId: String,
        content: String
    ) async {
        // Step 1: Generate local ID
        let localId = generateLocalId()

        // Step 2: Create pending message
        let pending = PendingMessageEntity.createEntity(
            in: localContext,
            localId: localId,
            conversationId: conversationId,
            toUserId: toUserId,
            content: content
        )
        try? localContext.save()

        // Step 3: Attempt send in background
        Task {
            await attemptSend(pending, retryCount: 0)
        }
    }

    private func attemptSend(_ pending: PendingMessageEntity, retryCount: Int) async {
        do {
            // Step 4: HTTP POST
            let response = try await remoteSource.sendMessage(
                toUserId: pending.toUserId,
                content: pending.content,
                requestId: pending.localId
            )

            // Step 5: Success! Convert to confirmed
            let confirmed = MessageEntity.createEntity(
                in: localContext,
                messageId: response.messageId,
                conversationId: pending.conversationId,
                sequence: response.sequence,
                fromUserId: getCurrentUserId(),
                toUserId: pending.toUserId,
                content: pending.content,
                status: "SENT"
            )

            localContext.delete(pending)
            try? localContext.save()

        } catch {
            // Step 6: Failure, schedule retry
            if retryCount < 5 {
                let nextRetryCount = retryCount + 1
                let delayMs = exponentialBackoff(nextRetryCount)

                try? localContext.save()

                // Schedule retry
                let delay = Double(delayMs) / 1000.0
                try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
                await attemptSend(pending, retryCount: nextRetryCount)
            } else {
                pending.failureReason = "Failed after 5 attempts: \(error.localizedDescription)"
                try? localContext.save()
            }
        }
    }

    private func exponentialBackoff(_ retryCount: Int) -> Int {
        let baseDelay = 2000
        return baseDelay * (1 << (retryCount - 1))
    }
}

Message Sync on App Launch

When the user opens the app after being away, we need to catch up. The app might have missed WebSocket events or push notifications.

class ChatViewModel(
    private val messagesRepository: MessagesRepository,
    private val conversationId: String
) : ViewModel() {
    val messages: Flow<List<Message>> = messagesRepository.observeMessages(conversationId)

    init {
        // On init (app open), sync with server
        viewModelScope.launch {
            syncMessages()
        }
    }

    private suspend fun syncMessages() {
        try {
            val lastMessageId = messagesRepository.getLastMessageIdInConversation(conversationId)
            messagesRepository.syncMessages(conversationId, lastMessageId)
        } catch (e: Exception) {
            Log.e("Chat", "Sync failed", e)
            // Retry will happen automatically on next app open
        }
    }
}

The sync endpoint:

GET /api/v1/conversations/{conversationId}/messages?lastSyncId=msg_xyz&limit=50

Returns all messages after msg_xyz. The client inserts these into the local database, merging with what's already there.

Storage Management

Eventually, the message database grows large. A user with years of chat history could have millions of messages.

Option 1: User-Managed (Like WhatsApp)

Let users choose:

Pros: Users have control, no surprise data loss. Cons: More UI complexity.

Option 2: Auto-Expire (Like Slack Free Tier)

Automatically delete messages older than a threshold:

// Background task, run once per day
suspend fun pruneOldMessages() {
    val retentionDays = 180  // Keep 6 months
    val cutoffTime = System.currentTimeMillis() - (retentionDays * 24 * 60 * 60 * 1000)

    messagesDao.deleteMessagesBefore(cutoffTime)
}

Pros: No user action needed, predictable storage. Cons: Data loss, users might miss old conversations.

Option 3: Hybrid

Auto-archive old messages locally, but let users still view them by syncing from the server. This requires more complex sync logic but offers the best UX.

Push Notifications for Chat

When the app is closed or backgrounded, the WebSocket connection dies. The only way to alert the user to a new message is a push notification.

Architecture

Server                          Firebase Cloud         Device
(chat backend)                  Messaging / APNs       (iOS/Android)

[new message arrives]
        |
        v
[send push notification]
        |
        ----------> [FCM/APNs] --------> [System notification]
                                                  |
                                                  v
                                        [User sees notification]
                                                  |
                                                  v
                                        [Tap notification]
                                                  |
                                                  v
                                        [App opens, syncs messages]

Payload Design

{
  "notification": {
    "title": "Alice",
    "body": "Hey, what's up?",
    "sound": "default"
  },
  "data": {
    "conversationId": "conv_123",
    "messageId": "msg_xyz",
    "senderUserId": "alice_123"
  }
}

The data payload is critical: it tells the app which conversation to open. Use it for deep linking.

Handling the Notification

Android (Kotlin):

class ChatFirebaseMessagingService : FirebaseMessagingService() {
    override fun onMessageReceived(remoteMessage: RemoteMessage) {
        val conversationId = remoteMessage.data["conversationId"]
        val messageId = remoteMessage.data["messageId"]

        if (conversationId != null) {
            // If app is foreground, don't show notification (use WebSocket instead)
            if (!isForeground()) {
                showNotification(remoteMessage)
            }

            // Sync this conversation
            viewModelScope.launch {
                messagesRepository.syncMessages(conversationId, null)
            }
        }
    }

    private fun showNotification(remoteMessage: RemoteMessage) {
        val notification = remoteMessage.notification ?: return
        val conversationId = remoteMessage.data["conversationId"] ?: return

        val intent = Intent(this, ChatActivity::class.java).apply {
            putExtra("conversationId", conversationId)
        }
        val pendingIntent = PendingIntent.getActivity(
            this,
            0,
            intent,
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
        )

        val builder = NotificationCompat.Builder(this, "messages")
            .setSmallIcon(R.drawable.ic_message)
            .setContentTitle(notification.title)
            .setContentText(notification.body)
            .setContentIntent(pendingIntent)
            .setAutoCancel(true)

        NotificationManagerCompat.from(this).notify(1, builder.build())
    }
}

iOS (Swift):

import UserNotifications

class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        willPresent notification: UNNotification,
        withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
    ) {
        // App is foreground, don't show notification (WebSocket handles it)
        completionHandler([])
    }

    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        didReceive response: UNNotificationResponse,
        withCompletionHandler completionHandler: @escaping () -> Void
    ) {
        // User tapped the notification
        let conversationId = response.notification.request.content.userInfo["conversationId"] as? String

        if let conversationId = conversationId {
            // Deep link: open the ChatViewController with this conversation
            navigateToChatViewController(conversationId: conversationId)
        }

        completionHandler()
    }
}

> 🔗 Building on Previous

In the Social Feed case study, we discussed caching and invalidation. Chat uses the same principles:

The difference: Chat's cache is the primary source, not a copy.

> 📝 Note

Offline support is often overlooked in interview discussions, but it's crucial. Interviewers love when candidates bring it up unprompted. It shows you understand real-world constraints and user experience.

> ❌ Common Pitfall

Don't try to build your own synchronization logic from scratch. Use a proven library:

These handle edge cases (e.g., app crashes mid-sync, user changes network while syncing) that you'd otherwise miss.

What's Next

We've now covered the two hardest aspects: real-time messaging and offline support. In the final section, we'll wrap up with a summary, key decisions, and practice variations for when the interviewer throws a curveball.

Back to Mobile System Design Interviews