Mobile Design Rounds
GuideAndroid + iOS10 min read

Offline-first mobile system design: one model, six case studies

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

TL;DR. Offline-first is one model, not a feature: the local database is the source of truth, the UI reads only from it, and the network is a background concern. What changes from one product to the next is three choices per kind of data: how it is written (online-only, queued or lazy), how it syncs (pull, push or hybrid), and who wins a conflict. This page gives the model in the Android guide's own terms, a table that makes those choices for chat, a news feed, photo upload, booking, analytics and push tokens, and a spoken outline for mid, senior and staff answers.

1. The model in plain words

The Android guide defines an offline-first app as one that can perform all, or a critical subset, of its core functionality without internet access. It also says the app should present local data immediately instead of waiting for the first network call, and should fetch data with battery and data status in mind.

The design starts in the data layer with two operations, reads and writes. At a minimum, an offline-first app must be able to perform reads without network access. The guide asks for at least two data sources for every repository that uses the network: a local data source and a network data source.

  • The local data source is the canonical source of truth. It should be the exclusive source of any data that higher layers read. Room, DataStore or plain files can back it.
  • The network data source is the actual state of the application. At best, the local one is synchronized with it.
  • Higher layers never talk to the network. Only the repository does.
  • Reads are reactive: the repository exposes observable types, and writes go to the local data source first so its observers update.

The Android data layer page says the same thing from the other side: each repository defines a single source of truth, and for offline-first a local data source such as a database is the recommended one. It also sorts work by lifetime: UI-oriented, app-oriented, and business-oriented work, which cannot be canceled and should survive process death, with WorkManager as the recommended tool.

The guide is Android-only. Apple has no page that defines "offline-first". What we can say from Apple's documentation is narrower. Core Data's overview says to use it to save permanent data for offline use or to cache temporary data. SwiftData's overview says an app that fetches from a web service might use it for a lightweight cache and limited offline functionality. Mapping the Android model onto iOS is our synthesis, and the case studies mark where iOS needs your own code.

2. The decision table

Every row is one operation from a case study. Read each as a short answer to "what would you pick, and why".

Data or operationRead strategyWrite strategySync styleConflict ruleCase study
Chat: send a messageReactive read from the local databaseLazy write through a local outboxHybrid: socket and FCM hints trigger a pull by cursorServer sequence (seq) is the authority; clientId dedupesChat app, sections 4, 5 and 8
News feed: like a postCache first, then revalidate; paged from the databaseLazy write with an idempotent PUT or DELETEPull by opaque cursor, plus a "New posts" hintLocal intent wins until the server confirmsNews feed, sections 4 to 6
Photo uploadUpload state read from the databaseBusiness-oriented queued or lazy work, resumable by blockPush-style upload, cloud changes pulled by cursor (hybrid)Version check on commit; keep both on a conflictPhoto and file sync, sections 4, 6 and 7
Booking: hold and paySaved trips read offlineOnline-only writePull on demand; ask the server after any interruptionServer owns inventory, price and state; version ignores stale responsesBooking app, sections 2, 5 and 7
Analytics eventsNot read by the UIQueued writeDrain the queue in batchesServer dedupes by event ID and batch IDAnalytics SDK, section 5
Push token registrationLocal registration record is the source of truthQueued write by a unique jobPush of local state to the backend; inbox pulled by cursorUpsert by installId; the local version counter shows what is uploadedPush SDK, sections 3, 4 and 6

Three notes on the table:

  • The Android guide calls analytics events a fit for queued writes, and a bank transfer a fit for online-only writes. Booking sits in the second group for the same reason: a queued write could let a user "book" a room that no longer exists.
  • The guide says lazy writes are the right choice when the data is critical to the app, and that they are nontrivial because the two sources can conflict. Chat, likes and photos are all lazy for that reason, and the conflict column is where the work is.
  • Hybrid sync is the guide's term for combining pull and push. Its example is a social feed pulled on demand, plus push for signed-in user data. The news feed, chat and photo rows fit that shape.

3. What "offline" really means on a device

"No network" is the easy case. The harder cases are the ones where your code stops running or the network is poor. Only what the guides and Apple's pages say is listed here.

On Android:

  • Process death. The data layer page says business-oriented work must survive process death and recommends WorkManager. A coroutine in viewModelScope is the wrong home for a send or an upload. The chat case study keeps the pending row in Room and the send in a unique worker for this reason.
  • Queues and monitors. The offline-first guide names two utilities: queues, to defer reads and writes until connectivity returns, and network connectivity monitors, as the signal to drain them. WorkManager covers both as persistent work, with NetworkType.CONNECTED and exponential backoff on Result.retry().
  • Conditions. The guide says to fetch under good conditions, such as when charging or on Wi-Fi. The photo case study turns that into constraints on the work request.
  • Limits that change by version. Foreground service types and job quotas differ by Android version. The photo and chat case studies say to verify them for your target SDK, and we do the same.

On iOS:

  • No WorkManager equivalent. The chat case study states it plainly: you write your own outbox table and drain it on send, foreground, network recovery and push. This is our synthesis from Apple's APIs.
  • Background transfers. Apple's URLSession page says background sessions let uploads and downloads continue while the app is not running. The waitsForConnectivity page says background sessions always wait for connectivity, and that a foreground session fails immediately when it is false.
  • Metered and constrained networks. allowsConstrainedNetworkAccess covers Low Data Mode. Apple advises limiting constrained access to user-initiated tasks and deferring discretionary ones.
  • Background processing. The BGProcessingTask page says it runs only while the device is idle and the system can interrupt it.
  • Force-quit. The photo and chat case studies record, from Apple's documentation, that a force-quit cancels background transfers and that nothing relaunches the app until the user opens it.
  • Connectivity monitoring. NWPathMonitor's abstract says it observes network changes. Its page had no further text in the JSON we fetched, so we do not claim more.

Storage pressure is a gap. We did not find a page that states how either OS evicts app data, so this page makes no claim about it. Cap your own caches, as the news feed and analytics case studies do, and treat the eviction question as an open item to research.

4. Conflict resolution and when last-write-wins is wrong

The guide says conflict resolution often requires versioning: the app tracks when changes occurred and passes that metadata to the network data source, which provides the absolute source of truth. Its common example is last write wins: devices attach timestamps, the network discards data older than its current state and accepts newer data.

Last-write-wins is simple, but it is wrong in several places the case studies cover:

  1. Anything ordered. Chat uses the server seq, because device clocks drift. createdAt is used only to place pending messages at the bottom.
  2. Anything with a base state. File sync commits with a baseVersion. If the server is ahead it answers 409, and the client keeps a conflicted copy so no edit is lost. Silent overwrite is the thing to avoid.
  3. Anything with a pending local intent. In the feed, a refresh that overwrites a row with a pending like makes the like flip off and on. The fix is to keep the local intent until the write is confirmed.
  4. Anything money-like. Booking avoids conflicts by refusing offline writes. The server decides, and the client only displays the state.
  5. Anything where timestamps are inputs. The guide's own disadvantage list for push sync includes that versioning for conflict resolution is nontrivial. Analytics sends two timestamps and lets the server correct for skew.

A useful rule: pick last-write-wins only when losing the older write is acceptable and the clocks can be trusted, or when the server assigns the order. Otherwise use a server-owned sequence or version.

5. The five mistakes

  1. Optimistic UI with no reconciliation. The UI shows the new state, and nothing says what happens when the server disagrees or a refresh overwrites it. The news feed case study keeps a pendingLike flag and rolls back on a permanent failure. See News feed, section 5.
  2. Retry without idempotency keys. A lost response plus a retry becomes a duplicate. Chat sends a clientId the server dedupes on, and booking persists an Idempotency-Key before the first send. See Chat app, section 4, and Booking app, section 5.
  3. Unbounded queues. An outbox that grows forever on a device that is offline for weeks becomes a storage and upload problem. The analytics SDK caps the queue by count and bytes, drops the oldest, and counts the drops. See Analytics SDK, section 5.
  4. Trusting device clocks. Booking computes the hold countdown from a server-provided duration on a monotonic clock. Chat orders by seq. See Booking app, section 4.
  5. Treating push as delivery. Push is a hint. The chat and push case studies send a small hint and then sync by cursor, so a missed push heals itself. Apple documents background pushes as low priority and not guaranteed. See Push SDK, section 6.

6. How to say this in an interview

Each outline below is about 45 seconds spoken. Replace the example with the product in the question.

Mid level. "I would make the local database the source of truth, so the screen always reads from Room, or SwiftData on iOS, and shows data instantly. The network layer writes into the database, and the UI updates from there. For writes I would save locally first and send in the background with retry, and show a pending state. If the user is offline I show cached data and a clear banner. I would also say what I do not handle yet, like conflicts, and ask whether the product needs them."

Senior. "I would split the feature by operation. Reads are reactive from the local source. For writes I pick one of three strategies: online-only for things like payments, queued for events like analytics, lazy for user data that must not be lost, like a sent message. Lazy writes need a durable queue, so WorkManager on Android and my own outbox on iOS, and every request carries an idempotency key so retries are safe. Sync is pull by cursor, with push as a hint. For conflicts I use a server sequence or a version, not device time. I would also name the failure modes: process death, a lost response, and a refresh that overwrites a pending change."

Staff. "I would start by asking which data can be wrong, for how long, and who pays when it is. That fixes the write strategy per data type and the conflict rule, and I would write the choices down in one table the teams share. Then the platform gap: Android gives a persistent work framework, iOS does not, so I would state the weaker guarantee on iOS openly and design recovery for launch and foreground. I would cover queue limits and drop policy, schema and wire versioning for old app versions, metrics like pending-write age and duplicate rate, a kill switch for sync behavior, and how the design gets split into modules several teams can own."

7. Further reading

Fetched for this page:

Verification notes (last verified 13 Oct 2026)

verified on 2026-10-13 by fetching the Android offline-first page and data layer page (definition, local and network data sources, reactive reads, the three write strategies, pull, push and hybrid sync, versioning and last write wins, queues and connectivity monitors, work lifetimes) and the Apple JSON for SwiftData, Core Data, BackgroundTasks, BGProcessingTask, URLSession, waitsForConnectivity, allowsConstrainedNetworkAccess and NWPathMonitor (abstract only; no discussion text came back). The table rows, the mistakes, the force-quit and no-equivalent-to-WorkManager statements, and all case study details come from our own case study files and their own verification notes, not re-fetched here. Synthesis: mapping the Android model to iOS, the "pick last-write-wins only when" rule, the item on timestamps as inputs, the row assignments of sync style for photo and push token rows, and the three spoken outlines. Unverified: how either OS evicts app data under storage pressure (deliberately not claimed), NWPathMonitor behavior, and BGTask run frequency. The Apple documentation links point to the human-readable pages; we fetched the JSON versions. The iOS sections have not yet been reviewed by an iOS engineer.

Keep reading

Follow new case studies

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