Free lesson · Mobile System Design Interviews
Chat Application - Deep Dive: Offline Support & Data Storage
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:
- "Storage Settings" → "Clear chat history" → "Delete messages older than 3 months" / "Delete all"
- Show storage usage: "Chat history: 500 MB"
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:
- Cache on device: Store messages locally.
- Invalidate on change: When a message status changes (PENDING → SENT → READ), update the cache.
- Sync on startup: Fetch fresh data on app launch to catch up.
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:
- Android: Room with WorkManager for background sync.
- iOS: Core Data with URLSession background tasks.
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.