Mobile Design Rounds
Build guideAndroid + iOS14 min read

Build it: a chat app on Android and iOS

Last reviewed . Drafted with AI assistance. Checked against official documentation where noted (how this is checked). Part of: Design a chat app (Android and iOS): outbox, idempotent sends and sync · The framework.

This is the implementation material behind the chat app interview case. It covers:

  • schema, REST and events
  • the send worker and the receive path
  • history, receipts, platform limits and the iOS version

None of it belongs in the 45 minutes. I draw it only if asked. The snippets use Room 2.x (androidx.room), Android's local database library. The official Room guide now shows Room 3 (androidx.room3, KSP only, SQLiteDriver required). The architecture is the same either way.

1. 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; gaps are allowed
    val createdAt: Long,           // device clock; used only for ordering pending messages
    val status: Status,
) {
    enum class Status { PENDING, SENT, DELIVERED, READ, FAILED }
}
  • clientId is how the app removes duplicates. The same message may arrive by WebSocket, by an FCM-triggered sync, and in the REST response to our own send. The unique index stops a second row from being inserted. The DAO (the class that holds the database queries) below turns the collision into an update of the first row. All three copies become one row.
  • seq comes from the server and gives one fixed order inside a conversation. It does not have to be contiguous (without gaps). A cursor sync repairs a gap, so a gap is not an error (the live ticker uses a stricter seq that detects gaps). Never order by device timestamps alone, because device clocks are not exact.
  • I do not use Room's @Upsert (insert, or update if the row exists) for this. It falls back to an update only when the primary key (localId) matches. An incoming copy has no localId. So a clientId collision would update nothing, and the ack data would be lost. Instead I insert with IGNORE. If the insert was ignored, I update by clientId:
@Dao interface MsgDao {
    @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertIgnore(m: MessageEntity): Long
    // the server copy fills in serverId and seq; a PENDING row becomes the server's status, any later status stays
    @Query("""UPDATE messages SET serverId = :serverId, seq = :seq,
        status = CASE WHEN status = 'PENDING' THEN :status ELSE status END
        WHERE clientId = :clientId""")
    suspend fun applyServerCopy(clientId: String, serverId: String, seq: Long, status: String)

    @Transaction suspend fun upsertByClientId(m: MessageEntity) {
        if (insertIgnore(m) == -1L) applyServerCopy(m.clientId, m.serverId!!, m.seq!!, m.status.name)
    }
}
  • In this design, the sync cursor is simply the last seq the device has seen. I call it after_seq here. Other cases use an opaque cursor (a value the app does not read) for the same idea.

2. 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>              (conversation list changes; used on cold start)

3. WebSocket events

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

ack(seq) tells the server how far this device has read, so it can stop holding older events for it.

I send through REST plus WorkManager, not the socket, on purpose. The worker (the class that WorkManager runs) gives retries, constraints (rules such as needing a network) and survival across process death without extra work.

4. The send worker

I write to the local database first and send later, so for a moment two versions of the truth exist. The server's seq decides which message comes first. A last-write-wins rule (the newest write replaces the older one) does not decide it. The send has to survive the app being stopped, so it runs in WorkManager, not in viewModelScope.

  1. The user taps send. The ViewModel calls repository.send(...). It inserts a row with status = PENDING and a new clientId in one Room transaction (all steps succeed or none do). The screen shows the row at once, because it observes Room.
  2. The repository enqueues (adds to the work queue) a unique SendMessageWorker per message, or per conversation to keep the order.
  3. The worker calls repository.sendPending(clientId). This reads the pending row and calls POST /messages.
  4. On success, the worker updates the row with serverId, seq and status = SENT.
  5. On a temporary failure (IOException, 5xx), the worker returns Result.retry(), and WorkManager applies backoff (a longer wait after each failure). After 8 attempts, the worker gives up and marks the row FAILED. Then one message that always gets a 5xx cannot hold up the rest of the conversation's chain.
  6. On a permanent failure, the worker marks the row FAILED and the screen shows "Tap to retry". My rule across these cases: IOException, 408, 429 and 5xx are temporary. Any other 4xx is permanent. To honour Retry-After, I would have to enqueue the work again with an initial delay, because the backoff policy is fixed. The worker below does not do that.

The define-work page describes no jitter in WorkManager's backoff, so I assume none. That is fine for the sends of one device. Jitter (a random change in each delay) matters for socket reconnects (section 5). There, millions of clients can retry at once.

With the 10 s base, the delays double (10, 20, 40 ... 1,280 s). The worker below checks runAttemptCount >= 8 at the start of a run. This gives:

  • Eight sends over about 21 minutes.
  • A ninth run, which marks the row FAILED, about 42 minutes after the first failure.
  • No effect from the cap. The androidx source caps backoff at 5 hours (the define-work page does not state a cap).

This is my arithmetic from the library's documented defaults and its source. It is not a measurement. Real delays differ by several seconds.

@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 {
        val id = inputData.getString("clientId") ?: return Result.failure()
        if (runAttemptCount >= 8) { repository.markFailed(id); return Result.success() }   // stop retrying; the row shows "Tap to retry"
        return when (repository.sendPending(id)) {
            SendOutcome.Done, SendOutcome.NothingToSend -> Result.success()
            SendOutcome.Retry -> Result.retry()   // IOException, 5xx, 429
            // permanent 4xx: the row is already FAILED in Room, so end this work as success().
            // (With APPEND a failed parent would cancel later sends; APPEND_OR_REPLACE avoids that, and success() keeps WorkInfo clean.)
            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)   // 10 s is the minimum; the default is 30 s
        .build(),
)

Why this is safe against duplicates: if the request reaches the server but the response is lost, the retry sends the same clientId. The server returns the original serverId and seq. It does not create a second message.

This is idempotency (doing it twice has the same result as doing it once) in practice. The booking app uses the same pattern for the payment. The staff guide walks through this exact lost-acknowledgement case at staff depth.

Ordering note: pending messages have no seq yet. The screen shows confirmed messages by seq, then pending ones by createdAt at the bottom. When an ack gives a row its seq, the row moves to its place.

5. The receive path in detail

The socket and FCM only tell the device that something changed. The data always comes from a cursor fetch.

Foreground (the app is on screen): hold one OkHttp WebSocket while the app is visible (tie it to the process lifecycle with ProcessLifecycleOwner). Each incoming message.new is upserted (inserted, or updated if it exists) into Room by clientId. The screen updates by itself.

Background: do not keep the socket open. Doze (the deep-sleep mode of an idle phone) suspends network access. App Standby delays background network activity (Doze and App Standby). So an idle socket stops being useful. A foreground service kept only to hold the socket costs battery. In my opinion, it also risks a Play policy problem. Instead:

  1. The server sends a high-priority FCM data message that contains only a hint (conversationId, maybe the seq), not the full content.
  2. FirebaseMessagingService.onMessageReceived enqueues a SyncWorker as expedited work (short, important work that the system runs soon). Expedited work has a quota (a usage limit). I set RUN_AS_NON_EXPEDITED_WORK_REQUEST, so ordinary work runs when the quota is used up. The worker must implement getForegroundInfo(), or setExpedited can crash on devices below Android 12 (WorkManager docs). Enqueue it directly from onMessageReceived, because FCM's exemption for expedited work after a high-priority message is brief.
  3. The worker calls GET /messages?after_seq=<last seq in Room>, upserts the results, then posts a notification. On a cold start (the app starts fresh), I call /sync first for the conversation list, then after_seq for each open thread.

A hint plus a sync is the pattern, for the reasons in the push SDK case. That case goes deeper on the push side.

Why not put the message in the push?

  • FCM caps a payload at 4 KB (message types).
  • FCM can collapse or drop queued messages (delivery) and documents no ordering.
  • A sync by cursor repairs itself. If you missed three pushes, one sync still fetches all three.

One catch: if high-priority FCM messages repeatedly do not produce a 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 1 s, 2 s, 4 s up to about 30 s, each randomized by ±50%). Always run a cursor sync (after_seq) after a reconnect to fill gaps. Jitter matters: without it, a server restart makes millions of clients reconnect at the same moment.

6. Reading history efficiently

Use Paging 3 (Android's library for loading lists in pages) with a Room PagingSource. Add 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. It shows at once from cache on cold start and refreshes in the background.

The snippet exposes the Room entity to keep it short. 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 a late event that would move a status back.
  • Receiver to server: batch read receipts (for example, send the highest seq read, not one call per message) and debounce them (wait a short time, then send once). If READ arrives before DELIVERED, keep READ. The forward-only rule makes the late DELIVERED change nothing.
  • Groups: store counts or a per-user map on the server. The client mostly needs "delivered to all" or "read by N".

8. Battery, data and platform limits

The limits that shaped the design, side by side. Each fact is already used elsewhere in this guide or on the interview page.

LimitAndroidiOSSo the design changes by
Background socketDoze suspends network access and App Standby defers background network activity; a foreground service just to hold it costs batteryNo socket in the background; close it when the app leaves the foregroundPush becomes a hint and a cursor sync fills the gap
Waking the app for a messageHigh-priority FCM data message; expedited work has a quota, so fall back to ordinary workSilent push is low priority, not guaranteed, throttled (Apple advises no more than two or three an hour) and discarded after a force-quit; an alert push with mutable-content: 1 and a Notification Service Extension (about 30 seconds to modify the payload) is what I use for chatiOS chat sends a visible push; both platforms sync by cursor
Notification after a hintFCM may downgrade high-priority messages that do not produce a visible notificationIf time runs out, the expiry callback must return what it has; if the handler is never called the system shows the original content. I return generic textOn Android the worker posts the notification, not the push
Catch-up work laterWorkManager; backoff default 30 s, minimum 10 sBGAppRefreshTask and BGProcessingTask are system-scheduled, not guaranteedNever promise a delivery time
Durable pending sendsPENDING rows plus WorkManager unique work survive process deathYour own outbox table; nothing runs after a force-quit until the next launchiOS pending messages may wait, and I say so
  • No polling loops. Use the socket only in the foreground. Otherwise use FCM plus sync.
  • Limit sync work to times with a network connection. For large media, also require an unmetered network or a battery that is not low.
  • Respect Android's background execution limits. I avoid foreground services here. If you need one, check the type and timeout rules for your target SDK.
  • Compress the payload (gzip), and fetch changes by cursor instead of full lists.

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

The design carries over: 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 tools change.

ConcernAndroidiOS
Local DBRoomSwiftData or Core Data (or GRDB)
Reactive UIFlow + Compose@Query (SwiftData) or @FetchRequest (Core Data) with SwiftUI; NSFetchedResultsController with UIKit
Outbox (durable pending sends)PENDING rows plus WorkManager 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 really changes:

  • The outbox is a table that you drain yourself (send every waiting message). Start a drain on send, on app foreground, on network recovery (NWPathMonitor), and on push.

- Those triggers overlap. An actor (a Swift type that guards its own data) lets another call start whenever it waits at an await. A second drain would send every message twice. - The sketch keeps one drain running and a flag that makes it do one more pass. A message saved during a drain then does not wait for the next trigger. - If the user force-quits the app, nothing runs in the background until the next launch, so pending messages may wait. Say so in the interview. It is a weaker guarantee than WorkManager gives.

  • Silent pushes are unreliable. Apple documents them as low priority, not guaranteed, and throttled (it advises no more than two or three per hour). The system discards a held one if the app is force-quit.

- For chat, send a visible alert push with mutable-content: 1. Let a Notification Service Extension (code iOS runs to change a push before it shows) fetch or decrypt the message. It must finish within its time limit, and fall back to generic text. - Apple's guide says the limit is about 30 seconds. When time is nearly up, the expiry callback must hand back whatever it has. The system shows the original content only if the handler is never called. - The rule "the push is a hint, and sync fills the gap" still applies.

  • No socket in the background. Close the WebSocket when the app leaves the foreground. Use beginBackgroundTask to finish a send in progress.
  • Dedupe is the same. A unique clientId constraint turns duplicate inserts into one row.

- In SwiftData, @Attribute(.unique) upserts on save for primitive-typed properties, per WWDC23 session 10195. - Adding it to an existing store needs a custom migration stage that removes duplicates first. - I would check CloudKit support before relying on it. Test on your target OS.

// Sketch: outbox drain on iOS (trigger it from send, foreground, reachability, push)
actor Outbox {
    private var draining = false, rerun = false     // reentrancy guard: triggers overlap
    // `store` is a @ModelActor (or a GRDB/Core Data actor); do not share a ModelContext across isolation domains.
    func drain() async {
        if draining { rerun = true; return }        // a trigger during a drain asks for one more pass
        draining = true
        defer { draining = false }
        repeat {
            rerun = false
            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)
                }
            }
        } while rerun
    }
}

10. More practice prompts

  1. How do you test this? (Fake API plus in-memory Room. Test the worker with TestListenableWorkerBuilder. Simulate duplicate and out-of-order events.)
  2. How would you tell a broken socket from a quiet one? (A heartbeat ping, which is a small message sent on a timer, a read timeout, and a token refresh before the long-lived connection expires.)

Further reading (official docs)

Verification notes (last reviewed 13 Oct 2026)

this guide is the "Build it" appendix of the chat case. I moved it to its own page on 2026-10-14 so the interview page stays inside 45 minutes. Its code blocks, API text and platform tables are carried over unchanged from the verified page. Only the section numbers (1 to 10), this intro and the cross-references changed. On 2026-10-13 I applied audit fixes: I re-read the APPEND_OR_REPLACE comment, the expedited-work wording (quota policy and getForegroundInfo) and the Room 3 callout against developer.android.com. The iOS guard against overlapping drains is my own addition, with no new Apple fetches. I audited the Android architecture against the official guidance on 2026-10-13 (layers, work lifetimes, offline-first terms). One difference remains: the Paging snippet exposes a Room entity, and the place of SyncEngine is simplified. Verified against Android docs: Constraints.Builder, the meaning of ExistingWorkPolicy.APPEND_OR_REPLACE, backoff defaults (30 s default, 10 s minimum), the expedited quota and fallback, FCM high-priority downgrade, and foreground-service timeouts (Android 15+). Verified against Apple docs (through the developer.apple.com JSON): background push throttling, discard on force-quit and the two-to-three-per-hour advice, mutable-content plus alert requirement and the extension limit of about 30 seconds, beginBackgroundTask meaning, BGProcessingTask idle-only behavior, and URLSessionWebSocketTask. The SwiftData upsert comes from WWDC23 session 10195. Unverified: the details of NWPathMonitor behavior, and how often BGAppRefreshTask really runs. Have an iOS engineer review the iOS part (section 9) before publishing. I compile-checked the Kotlin snippets offline on 2026-10-13. Newer releases of several of these libraries exist, and I did not run the snippets against them. The check used Kotlin 2.3.21 (AGP 9.1.0, compileSdk 36), Room 2.8.4, Paging 3.4.2, WorkManager 2.11.2 and Dagger 2.59, with Gradle offline and stubs only for app-defined placeholders (Hilt's @HiltWorker, ChatRepository, SendOutcome; Retrofit is not used here). The schema entity, the worker and the paging query compile. I ran the MsgDao upsert-by-clientId snippet through Room's KSP compiler 2.8.4 (KSP 2.3.6). It accepted the SQL and generated the DAO, but I did not run it against a real database. The @HiltWorker annotation is unverified because the hilt-work artifact was not cached, so I checked the worker without it. The PagingSource return type is generated by room-paging, which was not cached, so I checked only the SQL and a plain PagingSource declaration. I type-checked the Swift snippet (the Outbox actor) on 2026-10-13 against the iOS 27 simulator SDK (Swift 6.4, language modes 5 and 6), with stubs only for store, api and isTransient. I also ran a small macOS Swift 6 program with the same drain and rerun logic and a message added during a drain, and each message was sent once. The "Verified" and "Opinion" markers, library versions and compile claims above are exactly as before and apply to the snippets here. New on 2026-10-14: I re-checked the retry arithmetic in section 4. The WorkManager define-work page confirms the default of exponential backoff from 30 s and the 10 s minimum. The WorkRequest reference lists a maximum backoff constant. The androidx source (WorkRequest.kt and WorkSpec.kt) gives 5 hours and doubles the delay per attempt (10 s, 20 s, 40 s, and so on). The define-work page itself does not state the doubling or the cap value, and its example sequence for a 10 s base reads 20, 40, 80. So the 42 minute figure is an order of magnitude, not a promise. The 22 MB a year and 18 s figures on the interview page are arithmetic on stated assumptions, not measurements. The platform-limits table in section 8 rearranges facts already verified above. Review on 2026-10-14: I compared the code blocks with the page from before the split, and they are byte-identical (7 of 7). I corrected prose that claimed more than the sources or the code. Doze and App Standby are now described as suspending or deferring network access (the Doze page does not mention sockets, so that a socket goes useless is my inference). I read the FCM payload limit (4096 bytes) and the dropped or collapsed queued messages on the Firebase pages. I cut the old "ordered" claim because no page documents ordering. The Notification Service Extension wording now follows Apple: the original content is shown only if the completion handler is never called, and on expiry the callback must return what it has. The Retry-After sentence now says the worker does not honour it. I marked the 5 hour backoff cap as read from the androidx source, and the "no jitter" line as an assumption from a silent doc page. Re-read on 2026-10-14: the Apple background-updates page (low priority, not guaranteed, two or three an hour, held notification discarded on force-quit, 30 seconds), the Apple extension page (mutable-content, alert requirement, about 30 seconds), the WorkManager define-work and persistent pages, the Doze page, and the FCM priority page. Not re-fetched: the SwiftData session, BGTask behaviour, and foreground-service timeouts. I checked the links in this file with curl on 2026-10-14. A plain-English pass on 2026-10-14 used simpler words and defined each technical term at first use. It changed no fact, number, link, code block or marker.

Questions or corrections?

Add them to the discussion thread for this case on GitHub. To post, you need a free GitHub account. What you write there is public and follows GitHub's terms. Good answers are added to this page.

Keep reading

Get the next case study when it is published.

You get an email when a new case study is published, and nothing else.

You can unsubscribe at any time. Privacy.