Mobile Core Concepts
Offline-First and Sync
Why offline is the mobile design problem, and the sync and conflict strategies every system-design loop probes the moment the network drops.
Every interesting mobile app eventually has to answer "what happens when the user is on the subway?" - and the answer ripples through the architecture: where data lives, which direction it flows, how writes are queued, how a conflict between the device and the server is resolved. Offline-first is the design choice that organizes those answers around a single core rule: the local data source is the source of truth, the network is a sync transport, and the UI is a function of the local store. This page covers the playbook a mobile system-design panel runs through every loop - the three-model layout, the three write strategies and when each applies, pull / push / hybrid sync, last-write-wins conflict resolution and where it stops working, and how WorkManager fits.
What "offline-first" means
Offline-first does not mean offline-only. It means a critical subset of the app's read path works without network access, and at least the reads are off the network's critical path. Writes can be online-only and the app still qualifies. The minimum bar: open the app on a plane, see what you had before the plane, do something useful with it.
Three things change versus the naive "fetch on screen open" shape:
- The local data source is the source of truth. Usually Room. The UI reads from Room. The sync layer writes into Room. The reverse - UI reads bypassing Room and hitting the network directly - is the smell that breaks offline.
- The UI subscribes to streams, not promises. The Repository exposes
Flow<T>; the ViewModel converts viastateIn(WhileSubscribed(5_000)); the UI collects withcollectAsStateWithLifecycle. When the sync layer writes new data into Room, the UI updates automatically - no callback plumbing. - The sync layer is a separate concern. A worker, typically WorkManager-backed, fetches from the network and writes into Room. It can be paused, retried, delayed, gated on Wi-Fi or charging - none of which the UI cares about.
The discussion-grade payoff: the rest of the architecture falls out. Process-death recovery is free (Room is on disk). Rotation re-fetch is gone (the Flow already has the data). Loading spinners disappear from secondary visits (cached data renders, a background refresh updates it). These are not features you build separately - they are emergent properties of the local-is-truth invariant.
The three-model layout
Reusing one data class across the network, the database, and the UI is the most common smell that compromises offline-first - it couples the three layers, so any change ripples through all of them. The canonical shape is three models, one per layer; the mechanics live in Persistence. For an offline-first app, the rule is non-negotiable:
- Network DTO (
NetworkAuthor) - the API contract as the server happens to ship it today. - Local entity (
AuthorEntity) - the storage shape that survives schema migrations. - Domain model (
Author) - the shape the UI and business logic think in.
A panel will ask "what happens when the server renames a field?" The right answer is "the network DTO and one mapper function change; nothing else does." A candidate who reuses one model has to answer "every layer changes" - which is the wrong shape for an offline-first design.
Reads
In an offline-first repository, reads come from the local source only. The network never appears on the read path.
class OfflineFirstAuthorsRepository @Inject constructor(
private val dao: AuthorsDao,
private val syncer: AuthorsSyncer, // separate, not called from the read path
) : AuthorsRepository {
fun getAuthorStream(id: String): Flow<Author> =
dao.observeAuthor(id).map { it.asExternalModel() }
}getAuthorStream is the named convention - any function returning a Flow<X> ends in Stream so the call site reads as "stream of authors", not "get authors with side effects." OfflineFirst*Repository is the named convention for the implementation - it tells the next reader of the code which design choice this codebase made in one word.
LCE - Loading, Content, Error
The UI state wrapping a stream is the LCE pattern: a sealed interface with three cases.
sealed interface AuthorUiState {
data object Loading : AuthorUiState
data class Content(val author: Author) : AuthorUiState
data class Error(val cause: Throwable) : AuthorUiState
}
val state: StateFlow<AuthorUiState> =
repo.getAuthorStream(id)
.map<Author, AuthorUiState> { AuthorUiState.Content(it) }
.catch { emit(AuthorUiState.Error(it)) }
.stateIn(viewModelScope, WhileSubscribed(5_000), AuthorUiState.Loading)When LCE is the right shape: any screen where the user genuinely has to wait for first content - cold load on a fresh install, deep-link to an unfetched item, sync after data was cleared. When LCE is overkill: steady-state reads from a populated cache where the stream emits a non-empty list immediately and Loading never renders. For those, a data class FeedUiState(val posts: List<Post>, val isRefreshing: Boolean) is more honest than a three-case sealed interface where two cases never fire.
Writes - three strategies
Reads in offline-first are uniform: stream from local. Writes are where the design choices live. Three strategies, picked per write operation by trading off criticality, time-sensitivity, and how visible failure should be.
Online-only
The write fails if there's no network. The UI surfaces the failure and the user retries when they reconnect.
- Best for: transactions where committing now matters and committing later is worse than not committing - bank transfers, real-time bids, OAuth token exchange, "place order" with a deadline.
- Failure presentation: disable the action when offline, or surface an error immediately with a retry affordance. Never silently queue these.
- Tell the panel: "the write is online-only because the cost of late delivery exceeds the cost of failure."
Queued
The write is durable on the device immediately, but server-side commitment is best-effort. Failure to deliver any individual write is acceptable.
- Best for: analytics events, telemetry, crash breadcrumbs, "viewed" / "read" receipts.
- Mechanics: append to a local queue (a Room table, or MMKV for high-throughput append streams - see Persistence); a worker drains the queue with exponential backoff when network returns.
- Tell the panel: "we accept eventual delivery; the user never blocks on this write."
Lazy (write-through-local)
The write commits to local storage immediately, the UI reflects it immediately, and a worker syncs to the server in the background. If the server rejects or another device wrote concurrently, conflict resolution decides whose value wins.
- Best for: offline-first todos, notes, drafts, message composition, "liked" toggles, bookmark sets, photo uploads.
- Mechanics: the Repository writes to Room and returns; the worker reads
WHERE syncStatus = 'pending', posts to the server, marks rows synced or surfaces a conflict. The UI never touches the network directly. - Tell the panel: "the user's write is the source of truth on the device; the network reconciles eventually."
The decision framework
| Criticality | Time-sensitive? | User notified on failure? | Strategy |
|---|---|---|---|
| Cannot lose, must commit now | Yes | Yes, immediately | Online-only |
| Can lose, eventual is fine | No | No | Queued |
| Cannot lose, eventual is fine | No | Only on conflict | Lazy |
The wrong answer in interviews is "queue everything." Queueing payments is malpractice; running analytics through Lazy over-engineers a fire-and-forget path. Each write picks the cheapest strategy that satisfies the constraint.
Sync direction - three patterns
Reads come from the local store; the local store comes from the network. How the network fills the local store is the orthogonal axis. Three patterns:
Pull-based
The app fetches on demand - usually triggered by navigation, pull-to-refresh, or app open. The HTTP layer pulls, writes into Room, the UI's Flow re-emits.
- Pros: simplest to implement, no server push infrastructure, only fetches what's needed when it's needed.
- Cons: doesn't scale for relational data (loading a post needs its author, the author's avatar, the comments...), assumes the user is online when navigating, repeated visits re-fetch.
- Best for: apps with brief offline windows, content that doesn't change often, screens where staleness is acceptable.
Push-based
The server tells the device when data has changed; the device fetches deltas. Push triggers include FCM data messages (see Push and notifications), WebSocket / SSE channels (see Network protocols), or a polling worker on a schedule.
- Pros: minimal data transfer, works indefinitely offline (the device has everything until it's told otherwise), handles relational data better.
- Cons: the server needs a change feed and a delivery channel; clients need versioning to apply deltas correctly; conflict resolution becomes a real problem.
- Best for: chat apps, collaborative editors, anything where "user is offline for a week, comes back, expects to be caught up" is a normal flow.
Hybrid
Different data types pick different strategies. A news app pulls feed content on demand but pushes "new badge count" notifications. A messenger pushes messages but pulls historical search results. Most non-trivial apps end up here.
The decision framework
| Staleness tolerance | Network profile | Relational? | Strategy |
|---|---|---|---|
| Minutes to hours | Mostly online | Flat | Pull |
| Seconds, must propagate | Reliable when available | Any | Push |
| Mixed by data type | Mixed | Mixed | Hybrid |
A panel will hear "push for messages, pull for profile screens" and recognize the candidate has matched the strategy to the data shape, not picked one religion for the whole app.
Conflict resolution
Once the device can write while offline, two devices (or one device and the server) can produce conflicting states. The strategies, in order of cost:
Last-write-wins (LWW)
Each write carries a timestamp; the latest timestamp wins. The server stores the timestamp alongside the value and rejects (or merges-as-loser) writes whose timestamp is older than what's already there. Clock skew is handled at the server (server-authoritative clock) or via a logical clock if devices can't be trusted.
- When LWW is enough: per-field state where multi-device concurrent edits are rare and small data loss on conflict is acceptable - settings, preferences, "favorited" toggles, simple status fields.
- Failure mode: two devices edit the same field in the same minute; one device's edit is silently discarded. Acceptable for "user changed the theme on phone, then on tablet." Unacceptable for collaborative text editing.
Server-authoritative
Conflicts always resolve in the server's favor. The client retries with the server's view. Useful when the server already holds canonical state by design - inventory counts, account balances, leaderboard positions, anything with a uniqueness or accounting invariant.
Application-defined merge
For mergeable types - sets, counters, append-only lists - the conflict resolution is a domain operation. "Both devices added a friend" merges to "friend list contains both." Works when the operation is commutative.
CRDTs and operational transform
True collaborative editing (Google Docs, Figma, multi-user Notion) needs more than LWW or merge. The data structure itself is conflict-free by construction (Conflict-free Replicated Data Types) or the conflict is detected via vector clocks (each replica maintains a per-node counter; concurrent writes are flagged for merge) and resolved by operational transform. These are real engineering investments and rarely interview-graded beyond naming them: "for collaborative editing, LWW isn't enough; you'd reach for CRDTs or OT with vector clocks."
The 90% answer for mobile apps: LWW with per-field timestamps, plus server-authoritative override for fields the server cares about, plus a sealed Conflict<T> type in the model layer for the cases that need user resolution.
WorkManager as the sync substrate
The sync layer is a worker. WorkManager is the right substrate; the mechanics live in Background work and scheduling. The design-level point: what WorkManager gives you that a raw coroutine in Application.onCreate does not is the substrate offline-first needs.
- Persistence across process death. The worker queue survives a kill; a queued sync is still scheduled when the user reopens the app days later.
- Constraints.
NetworkType.UNMETERED(Wi-Fi only),requiresCharging = true,requiresBatteryNotLow = true. The right defaults for background sync - fetching 200 MB of media on cellular is a user complaint waiting to happen. - Uniqueness.
enqueueUniqueWork(SYNC_KEY, KEEP, work)ensures only one sync is in flight regardless of how many call sites request it. - Expedited work. For sync that should run as soon as possible without violating Doze - a chat app's reconnect after the user opens the app from a notification.
- Backoff. Built-in exponential backoff for retried failures.
"I'd start a coroutine in Application.onCreate to run the sync" fails the offline-first test - the coroutine dies with the process, retries fall on the floor, and Doze restrictions ignore it. WorkManager is what makes the design durable across the conditions an offline-first app is built for.
How this fits with the rest
Offline-first composes with the rest of the data-layer concepts on this catalog:
- Persistence is the substrate - Room as the SSOT, DataStore for small configs. The model-per-layer pattern lives there.
- Caching strategies is the layer above and below - HTTP caching helps the sync worker; in-memory caches sit in front of Room for hot reads.
- Pagination is the offline-first read pattern at scale - Paging 3 plus
RemoteMediatoris the canonical shape, where Room is the Paging source of truth andRemoteMediatortriggers network fills when scrolling past the cache horizon. - Background work and scheduling is where the sync worker actually lives.
- Network protocols is what the sync worker speaks - long-poll, WebSocket, SSE, or HTTP for the delivery channel.
Naming conventions
The conventions a panel listens for when grading data-layer talk:
getXStream(): Flow<X>for observable reads. TheStreamsuffix telegraphs "this is reactive, not a one-shot read."OfflineFirstNewsRepository,InMemoryNewsRepository,DefaultNewsRepositoryfor repository implementations. The prefix names the design choice.FakeAuthorsRepositoryfor the test double.Fakeis the named convention;Mockis for verification frameworks, not for substitute implementations.
These names cost nothing and pay back in interview-time clarity - "I'd implement OfflineFirstFeedRepository against FeedRepository" is one sentence that tells the panel which design shape the candidate is reaching for.
See also
- Persistence - the local store that becomes the source of truth; the model-per-layer pattern that offline-first depends on.
- Caching strategies - HTTP cache for sync-worker reads; layered caches in front of Room.
- Pagination - Paging 3 with
RemoteMediatoris the offline-first read pattern at scale. - Background work and scheduling - WorkManager constraints, expedited work, backoff - the mechanics of the sync substrate.
- Push and notifications - FCM data messages as the push-based sync trigger.
- Network protocols - WebSocket and SSE for real-time delivery; long-poll as the fallback.
Used in
- Messenger App - push-based sync over WebSocket for new messages, pull for historical search; lazy writes for send (the message appears in the UI before the server ACKs); LWW per-message-field with server-authoritative timestamp.
- Newsfeed App - hybrid sync: pull for the paginated feed (Paging 3 + RemoteMediator), push for "you have new posts" badge; lazy writes for likes and bookmarks, queued writes for impression analytics.
- Photo Gallery App - lazy writes for the upload queue (the photo appears in the album immediately, the upload happens in WorkManager); LWW on album metadata.
- Doc Editor App - the only one of these that needs more than LWW: operational transform with vector clocks for collaborative editing; the queue of unsynced ops is a Room table.
- Music Player App - the only fully online-only design in the catalog: streams require the network on the read path, but playlist edits are lazy writes and offline-saved tracks invert the model (download is the explicit write-to-Room).
Sources: Build an offline-first app · Data layer · WorkManager · Architecture recommendations
Done reading? Mark it so it sticks in your dashboard.