Mobile Design Rounds
Case studyAndroid + iOS11 min read

Design a chat app (Android and iOS): outbox, idempotent sends and sync

Last verified . Drafted with AI assistance and checked against official documentation (how this is verified). Part of: The framework · Offline-first guide · Staff-level guide.

How to read this. Sections 1 to 3 and 5 to 9 are the platform-neutral design, with Kotlin/Android code. Section 10 shows what changes on iOS. The architecture is identical on both; the platform primitives differ.

TL;DR. Treat the local database as the single source of truth. The UI only reads from Room. Sends go into a local outbox and are drained by WorkManager. Receives arrive over a WebSocket in the foreground and FCM in the background, and both are deduplicated by a client-generated ID. Assume at-least-once delivery; never promise exactly-once.

1. Clarify the requirements (about 5 min)

Ask before drawing anything. Good opening questions:

  • 1:1 only, or group chats too? (Assume both, with groups up to about 200 members.)
  • Text only, or media? (Assume text now, media as a follow-up.)
  • Must it work offline? (Yes: users can compose and read history with no connection.)
  • Do we need delivery and read receipts? (Yes, basic ones.)
  • Single device or multi-device? (Assume one active Android device; mention multi-device as a follow-up.)
  • Scale? (Assume 50M MAU. The scale mostly matters to the backend, so keep the focus on the client.)

Functional: list conversations, open a thread, send and receive messages, see sent/delivered/read state, get notified when the app is closed.

Non-functional (the part that makes it a mobile question):

ConcernTarget
Perceived send latencyMessage appears instantly (optimistic)
ReliabilityNo lost messages, no duplicates, correct order
OfflineRead history and compose with no network
Battery and dataNo persistent background connection, no polling loop
StartupConversation list shown from cache in under 1s

Out of scope for now: end-to-end encryption, voice and video, search. Say this out loud so the interviewer can redirect you.

2. High-level architecture (about 10 min)

observesenqueuesendeventswritesUICompose / SwiftUIState holderViewModelRepositoryone entry pointLocal databasesource of truthOutbox workercalls the repositorySync enginedata layer, dedupeREST APIidempotent sendWebSocket + pushforeground / background
Chat app architecture: the UI observes a local database; sends go through an outbox; sync writes back to the database.

Official architecture mapping: Compose screen plus ViewModel form the UI layer (the ViewModel is the state holder); ChatRepository is the data layer entry point, with Room, the REST API, and the socket as data sources; no domain layer (add use cases only if logic is shared). See the Android architecture recommendations.

The key decision: the UI never talks to the network. It observes Room. The network layer writes into Room, and the UI updates from that. This gives offline reading, instant optimistic sends, and a single place to resolve conflicts.

Why not hold messages in memory and fetch on open? Because process death is routine on Android. The system can kill your app in the background at any time, and in-memory state vanishes with it.

3. Data model and API

Local schema (Room)

@Entity(
    tableName = "messages",
    indices = [
        Index(value = ["clientId"], unique = true),
        Index(value = ["conversationId", "seq"]),
    ],
)
data class MessageEntity(
    @PrimaryKey(autoGenerate = true) val localId: Long = 0,
    val clientId: String,          // UUID generated on device; the idempotency key
    val serverId: String? = null,  // null until acked
    val conversationId: String,
    val senderId: String,
    val body: String,
    val seq: Long? = null,         // per-conversation sequence assigned by server
    val createdAt: Long,           // device clock; used only for ordering pending messages
    val status: Status,
) {
    enum class Status { PENDING, SENT, DELIVERED, READ, FAILED }
}
  • clientId is the whole dedupe story. The same message may arrive via WebSocket, FCM-triggered sync, and the REST response to our own send, and the unique index plus OnConflictStrategy.IGNORE/upsert collapses all three into one row.
  • seq comes from the server and gives a total order within a conversation. Never order by device timestamps alone; clocks drift.

REST

POST /v1/conversations/{id}/messages      body: { clientId, body }
     → 200 { serverId, seq, createdAt }   (idempotent on clientId)
GET  /v1/conversations/{id}/messages?after_seq=123&limit=50
GET  /v1/sync?since=<cursor>              (conversations changed since cursor)

WebSocket events

server → client:  message.new, message.status, conversation.updated
client → server:  ack(seq), typing   (send itself goes via REST/Worker for retry guarantees)

Sending through REST plus WorkManager, rather than the socket, is deliberate: the Worker gives retries, constraints, and survival across process death for free.

4. The send path (the core of the answer)

Official write strategy: a lazy write (local first, then a queued network update), so it needs conflict resolution; here the server seq is the authority, not last-write-wins. This is business-oriented work (it must survive process death), so it belongs to WorkManager, not viewModelScope.

  1. User taps send. The ViewModel calls repository.send(...), which inserts a row with status = PENDING and a fresh clientId, in one Room transaction. The UI shows it immediately because it observes Room.
  2. The repository enqueues a unique SendMessageWorker per message (or per conversation, to preserve order).
  3. The worker calls repository.sendPending(clientId), which reads the pending row and calls POST /messages.
  4. On success, update the row with serverId, seq, status = SENT.
  5. On a transient failure (IOException, 5xx), return Result.retry(); WorkManager applies backoff.
  6. On a permanent failure (4xx other than 429), mark FAILED and show "Tap to retry".
@HiltWorker
class SendMessageWorker @AssistedInject constructor(
    @Assisted ctx: Context, @Assisted params: WorkerParameters,
    private val repository: ChatRepository,   // workers go through the repository, never the DAO or API
) : CoroutineWorker(ctx, params) {

    override suspend fun doWork(): Result =
        when (repository.sendPending(inputData.getString("clientId")!!)) {
            SendOutcome.Done, SendOutcome.NothingToSend -> Result.success()
            SendOutcome.Retry -> Result.retry()   // IOException, 5xx, 429
            // permanent 4xx: the repository marked the row FAILED. Return success(), not failure(): a failed parent fails appended children
            SendOutcome.PermanentFailure -> Result.success()
        }
}

// enqueue
WorkManager.getInstance(ctx).enqueueUniqueWork(
    "send-${conversationId}",            // one chain per conversation keeps order
    ExistingWorkPolicy.APPEND_OR_REPLACE,
    OneTimeWorkRequestBuilder<SendMessageWorker>()
        .setInputData(workDataOf("clientId" to clientId))
        .setConstraints(
            Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
        )
        .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS)
        .build(),
)
App (UI + local database)Outbox workerServerInsert row: PENDING + clientIdPOST message (clientId)Stores message onceAck lost in transit✕Retry POST (same clientId)200: same serverId + seqMark SENT, store seq
Send path with a lost acknowledgement: the retry reuses the same clientId, so the server stores the message once.

Why this is safe against duplicates: if the request reaches the server but the response is lost, the retry sends the same clientId, and the server returns the original serverId/seq instead of creating a second message. This is idempotency in practice.

Ordering note: pending messages have no seq yet. Render confirmed messages by seq, then pending ones by createdAt at the bottom. When an ack assigns seq, the row slots into place.

5. The receive path

Sync here is hybrid: push-based hints (socket, FCM) trigger a pull-based cursor fetch.

Foreground: hold one OkHttp WebSocket while the app is visible (tie it to the process lifecycle with ProcessLifecycleOwner). Each incoming message.new is upserted into Room by clientId. The UI updates on its own.

Background: do not keep the socket open. Android Doze and App Standby will throttle it, and a foreground service just to keep a chat socket alive is a battery and policy liability. Instead:

  1. The server sends a high-priority FCM data message containing only a hint (conversationId, maybe the seq), not the full content.
  2. FirebaseMessagingService.onMessageReceived enqueues a SyncWorker (expedited; subject to a quota, so choose an out-of-quota policy).
  3. The worker calls GET /messages?after_seq=<last seq in Room>, upserts the results, then posts a notification.

Why a hint plus sync, rather than putting the message in the push? Payload size limits, delivery isn't guaranteed or ordered, and a sync-by-cursor is self-healing: if you missed three pushes, one sync still fetches all three. Note that if high-priority FCM messages repeatedly don't result in a user-visible notification, FCM may downgrade them to normal priority, so post the notification from the worker.

Reconnect: when the socket drops, reconnect with exponential backoff plus jitter (for example 1s, 2s, 4s up to about 30s, each randomized ±50%), and always do a cursor sync (after_seq) on reconnect to fill gaps. Jitter matters: without it, a server restart makes millions of clients reconnect at the same instant.

6. Reading history efficiently

Use Paging 3 with a Room PagingSource, plus a RemoteMediator that fetches older pages from the API when the user scrolls past the cached range. The conversation list is also a Room query, so it renders instantly from cache on cold start and refreshes in the background.

The snippet exposes the Room entity for brevity; in a larger app, map it to a UI model in the repository.

@Query("SELECT * FROM messages WHERE conversationId = :id ORDER BY COALESCE(seq, 9223372036854775807), createdAt")
fun pagingSource(id: String): PagingSource<Int, MessageEntity>

7. Delivery and read receipts

  • Server-to-sender: message.status events (DELIVERED, READ) update the row's status. Statuses only move forward; ignore out-of-order downgrades.
  • Receiver-to-server: batch read receipts (for example, send the highest seq read, not one call per message) and debounce them.
  • Groups: store counts or a per-user map server-side; the client mostly needs "delivered to all / read by N".

8. Failure modes and edge cases

ScenarioHandling
Process killed mid-sendThe row is already PENDING in Room, and the WorkManager job survives; it resumes on its own
Response lost after server acceptedRetry with the same clientId; the server dedupes
Push and socket deliver the same messageUnique clientId / serverId index; upsert is a no-op
App offline for daysOn reconnect, after_seq sync; if the gap is too large, fetch the latest page and mark a "gap" the user can scroll to fill
Clock skew on deviceOrdering uses server seq; createdAt only for pending messages
Permanent failure (for example, blocked user)Mark FAILED; offer retry or delete
Low memory / background restrictionsKeep work in WorkManager, not in a Service or coroutine scope tied to the UI

9. Battery, data and platform limits

  • No polling loops. Socket only while foregrounded; FCM plus sync otherwise.
  • Constrain sync work to a network connection; for large media, add unmetered or battery-not-low constraints.
  • Respect Android's background execution limits and, on newer versions, the foreground-service type and timeout rules (for example, dataSync services are capped at 6 hours per 24 hours when targeting Android 15). Verify the current rules for your target SDK before relying on any specific behavior.
  • Compress the payload (gzip), and fetch deltas by cursor instead of full lists.

10. How to build a chat app on iOS (Swift)

The design is the same: local database as the source of truth, an outbox, a client-generated clientId for idempotency, server seq for ordering, and cursor-based sync. Only the platform primitives change.

ConcernAndroidiOS
Local DBRoomSwiftData or Core Data (or GRDB)
Reactive UIFlow + Compose@Query / NSFetchedResultsController with SwiftUI
Durable send queueWorkManager unique workYour own outbox table and sender; there is no WorkManager equivalent
Foreground socketOkHttp WebSocketURLSessionWebSocketTask
Background wakeFCM high-priority data messageAPNs silent push (best effort), or an alert push with a Notification Service Extension
Background syncExpedited workerBGAppRefreshTask / BGProcessingTask (system-scheduled, not guaranteed)
PaginationPaging 3 + RemoteMediatorCursor-based loading with a fetch limit; no official paging library

What is genuinely different:

  • The outbox is drained by your own code. Trigger it on send, on app foreground, on network recovery (NWPathMonitor), and on push. If the user force-quits the app, nothing runs in the background until the next launch, so pending messages may wait. Say this explicitly; it is a weaker guarantee than WorkManager gives you.
  • Silent pushes are unreliable. Apple documents them as low priority, not guaranteed, and throttled (it advises no more than two or three per hour); a held one is discarded if the app is force-quit. For chat, send a visible alert push with mutable-content: 1, and let a Notification Service Extension fetch or decrypt the message within its time limit (Apple's guide says about 30 seconds; the system then shows the original content), falling back to generic text. The "push is a hint, sync fills the gap" rule still applies.
  • No socket in the background. Close the WebSocket when the app leaves the foreground; use beginBackgroundTask to finish an in-flight send.
  • Dedupe is the same. A unique clientId constraint makes duplicate inserts collapse (in SwiftData, @Attribute(.unique) upserts on save per WWDC23 session 10195, for primitive-typed properties; test this on your target OS).
// Sketch: outbox drain on iOS (trigger it from send, foreground, reachability, push)
actor Outbox {
    func drain() async {
        for msg in await store.pending() {          // ordered by createdAt
            do {
                let ack = try await api.send(msg.conversationId, clientId: msg.clientId, body: msg.body)
                await store.markSent(msg.clientId, serverId: ack.serverId, seq: ack.seq)
            } catch where error.isTransient {
                return                              // keep order; retry on the next trigger
            } catch {
                await store.markFailed(msg.clientId)
            }
        }
    }
}

11. What changes by level

  • Mid-level: a working architecture with Room as the source of truth, a send flow with retry, and basic offline handling.
  • Senior: idempotency via clientId, server seq ordering, the FCM-as-hint pattern, and backoff with jitter. They should also bring up Doze and battery without being prompted.
  • Staff: multi-device sync and conflict handling, E2E encryption's impact on sync and search, observability (delivery-latency metrics, retry rates), rollout via feature flags, and a modularization plan so several teams can own pieces.

12. Follow-up questions to expect

  1. How would you add image and video messages? (Upload service, resumable upload through WorkManager, thumbnail first, progress in Room.)
  2. How do you support multiple devices? (Per-device cursors, server as the ordering authority, new-device history bootstrap.)
  3. How would you add end-to-end encryption? (Per-device keys; the effect on server-side search and on push content.)
  4. How do you test this? (Fake API plus in-memory Room; test the Worker with TestListenableWorkerBuilder; simulate duplicate and out-of-order events.)
  5. How do you measure it? (Time-to-send-ack, failed-send rate, duplicate rate, reconnect frequency.)

Further reading (official docs)

Verification notes (last verified 13 Oct 2026)

Android architecture audited against the official guidance on 2026-10-13 (layers, work lifetimes, offline-first terms); remaining deviation: the Paging snippet exposes a Room entity, and SyncEngine placement is simplified. Verified against Android docs: Constraints.Builder, ExistingWorkPolicy.APPEND_OR_REPLACE semantics, backoff defaults (10s minimum), expedited quota and fallback, FCM high-priority deprioritization, foreground-service timeouts (Android 15+). Verified against Apple docs (via developer.apple.com JSON): background push throttling, discard on force-quit and the two-to-three-per-hour guidance, mutable-content plus alert requirement and the roughly 30 second extension limit, beginBackgroundTask semantics, BGProcessingTask idle-only behavior, URLSessionWebSocketTask; SwiftData upsert from WWDC23 session 10195. Unverified: NWPathMonitor behavior details and how often BGAppRefreshTask actually runs. The iOS sections have not yet been reviewed by an iOS engineer. Kotlin snippets compile-checked offline on 2026-10-13 against Kotlin 2.3.21 (AGP 9.1.0, compileSdk 36), Room 2.8.4, Paging 3.4.2, WorkManager 2.11.2, Dagger 2.59, with stubs only for app-defined placeholders. The @HiltWorker annotation is unverified: missing cached hilt-work artifact (the worker was checked without it). Swift snippets type-checked on 2026-10-13 against the iOS 27 simulator SDK (Swift 6.4, language modes 5 and 6) with stubs only for app-defined placeholders.

Keep reading

Follow new case studies

Subscribe with any RSS reader. Found a mistake? Open an issue on GitHub.