Offline-first Android is easier to describe than it is to design. Most teams can agree on the headline: keep a local database, make the UI read from it, and sync with the server when the network is available.
The harder part is making that rule precise enough that the codebase can follow it under pressure. What reads from the network? What writes to Room? What happens when the server has moved on while the phone has been offline for three days? When is it safe to delete local data?
The architecture I trust is simple, but strict:
- The UI observes Room through
Flow. - Sync writes network results into Room.
- User writes are persisted locally first, then uploaded by background work.
- Eviction only happens when the app can actually get the data back.
That is the practical meaning of “single source of truth” in an offline-first Android app. Room is not necessarily the system of record. The server often owns that role. Room is the source of truth for UI state: the one read model the app renders from, regardless of whether the network is fast, slow, or gone.
Start with one read path
The first rule is the most important one: screens should not choose between the network and the database.
In a connected-first app, it is common for a repository to fetch from the API, return the response, and use the database as a fallback or cache. That pattern looks harmless until connectivity becomes the least reliable input in the system. Now the same screen can show different data depending on signal strength, and the offline path becomes the exception branch in the code.
Offline-first apps should invert that relationship. The UI renders Room. The network feeds Room.
class ObservationRepository( private val dao: ObservationDao,) { fun observeObservations(): Flow<List<Observation>> = dao.observeAll() .map { rows -> rows.map(ObservationRow::toDomain) }}This repository is intentionally boring. It does not know whether the last sync was five seconds ago or five days ago. It exposes the local read model, and the rest of the app can build stable UI state from that stream.
The benefit is not just offline behavior. It also simplifies normal connected behavior. A refresh, a push notification, a manual retry, and a background sync all update the same store. The UI does not need a special path for each cause. It only reacts to changes in Room.
Let sync write to Room, not to screens
Once Room is the read model, sync has a narrower job. It should not fetch data and hand it directly to a ViewModel. It should fetch data, reconcile it with the local store, and commit the result.
class ObservationSyncWorker( private val api: ObservationApi, private val dao: ObservationDao,) { suspend fun sync(): Result { val remote = api.fetchObservations()
dao.withTransaction { dao.upsert(remote.map(ObservationDto::toRow)) dao.markSynced(remote.map { it.id }) }
return Result.success() }}The important detail is what this worker does not return: a list for the UI to render. If the write succeeds, Room emits, and any screen observing that data updates through its normal path.
This boundary keeps sync honest. WorkManager, push-triggered refreshes, manual pull-to-refresh, and app-start reconciliation can all use the same principle: network results become database changes. Database changes become UI changes.
That separation also makes failure easier to reason about. A failed sync is not a failed screen render. It means the local read model is older than the server, and the app can expose that state deliberately with “last synced” timestamps, pending badges, retry affordances, or quiet background recovery.
Treat Room as the UI source of truth
“Single source of truth” is useful, but only if the boundary is clear.
In many real systems, the phone is not the final authority. The server may still be the system of record for reporting, analytics, moderation, collaboration, or organization-wide workflows. Calling the local database the source of truth can sound like the phone gets to overrule the rest of the system.
That is not the claim. The better claim is:
Room is the source of truth for what the Android UI can say right now.
That distinction matters in offline-first design. A user should not see one version of reality from the API when online and a different version from Room when offline. The app should have one local model of what it currently knows, including what is fresh, what is pending upload, what failed, and what still needs reconciliation.
On the field app that shaped this article, users could spend days outside reliable coverage while collecting observations. The server remained the long term system of record, but the phone had to be trustworthy in the user’s hands. That meant the UI could not be a thin reflection of the network. It had to be a reflection of the durable local state.
Audit conflicts before building conflict resolution
Offline-first discussions often jump too quickly to merge algorithms. Sometimes you need them. Often, you need a simpler audit first.
Go table by table and answer these questions:
- Who writes this table? User, server, or both?
- Can two writers edit the same row while one device is offline?
- If they can, what field wins, and why?
- Is the operation a create, update, delete, or append?
- Does the row need a pending, failed, or conflict state visible to the UI?
Many offline-first apps have fewer true conflicts than they first appear to. A reference table may be server-authored only. A message log may be append-only. A field observation may be created by one user and never collaboratively edited. Those tables still need careful sync, but they may not need expensive merge machinery.
Spend the complexity where the audit says two histories can really collide. If a user can edit a row while the server can also mutate that row, make the policy explicit. Use revision tokens, updated timestamps, server-side validation, field level merge rules, or a visible conflict state. The exact tool depends on the domain. The important part is not letting the policy hide inside a ViewModel or a best-effort mapper.
For the field app, most rows had one clear author. Observations were closer to witness statements than shared documents. That domain property was more valuable than a generic conflict-resolution framework. The useful engineering move was noticing it early and keeping the sync model appropriately small.
Be careful with cache instincts
The easiest offline-first bug to ship is treating durable local data like a cache.
Connected apps can delete cached data because deletion is usually a promise to fetch it again later. Offline-first apps cannot make that promise casually. If the user is days away from connectivity, data that still exists on the server can still be gone in the only place that matters: the device in their hand.
This is especially easy to miss in pruning jobs, TTL policies, lazy detail fetches, and storage cleanup tasks. The rule I use now is simple: eviction is allowed only when re-obtainability is real, not theoretical.
suspend fun pruneExpiredReferenceData(now: Instant) { if (!syncStatus.recentlyReachedServer()) return
dao.deleteExpiredReferenceData( asOf = now, keepRowsWithPendingUpload = true, )}The guard is not clever, but it encodes the right promise. If the app has not recently reached the server, it should not delete data merely because a timestamp expired. User-authored data deserves an even stricter rule: pending local writes are not cache entries, and pruning should never treat them like one.
In the field app, this was not theoretical. Downloaded content could be actively useful long after its ideal refresh window had passed. The correct policy was not “delete old data.” It was “delete replaceable data only after we have good reason to believe replacement is available.”
The shape to aim for
A reliable offline-first Android data layer is less about one library choice and more about preserving a few boundaries:
- UI reads from Room. Prefer observable local queries over request-driven screen state.
- Sync writes to Room. Network responses should update the local model, not bypass it.
- User actions persist locally first. Upload can be delayed; losing the user’s work cannot.
- Conflicts are audited by table. Do not build generic merge machinery until the schema proves you need it.
- Eviction depends on re-obtainability. A timestamp alone is not enough.
That architecture is not dramatic, which is part of its value. It gives every piece of the app a clear job. Room holds the UI’s current truth. Sync workers move the app toward the server’s truth. The UI can show freshness, pending work, and failure states without changing where it reads from.
If those boundaries hold, offline-first stops being a special mode. It becomes the normal shape of the app, whether the last successful sync was seconds ago or several days behind.
The constraints discussed here came from an offline-first Android app I led for rangeland monitoring and veterinary access in northern Kenya and Namibia. The InfoRange case study covers the broader project, including offline maps, encrypted local storage, and battery-aware GPS.