Offline-First Without a Sync Nightmare
Offline-first gets complicated when the network is treated as the source of truth. Invert it: the UI reads only from the database, the network is a background writer. Here's the read path, the harder write path, and the conflict decisions you must make explicitly.
Hessam Rastegari
Senior Android Developer · 12 years shipping Android
TL;DR — Most offline-first pain comes from one mistake: treating the network as the source of truth and the database as a cache. Invert it. The UI reads from the database and nothing else; the network is a background writer. Reads become trivial. Writes are the genuinely hard part — write locally first, queue the sync, and decide your conflict rule explicitly instead of inheriting one.
The inversion
The conventional structure has the ViewModel call the repository, which calls the API, which returns data, which becomes UI state — with the database bolted on as a cache consulted when the request fails. Every screen then needs loading, error, empty, and stale states, all coupled to connectivity.
The offline-first structure:
class FeedRepository @Inject constructor(
private val dao: FeedDao,
private val api: FeedApi,
) {
// The only read path. Always has data. Never fails because of the network.
fun observeFeed(): Flow<List<Post>> = dao.observeFeed().map { it.map(Entity::toDomain) }
// The network never reaches the UI — it writes to the DB, and the DB notifies.
suspend fun refresh(): Result<Unit> = runCatching {
dao.upsert(api.feed().map(Dto::toEntity))
}
}
The ViewModel exposes observeFeed(). It doesn't know whether the device is online, and it doesn't
need to. When refresh() succeeds, Room emits and the UI updates itself.
What this deletes: the entire category of bugs where a response arrives after the screen closed, where two requests race, or where a cache and a screen disagree.
Reads are now easy. Writes are the real work.
A write has to appear instantly, survive process death, retry on reconnect, and eventually reconcile with the server. That's four separate problems, and each needs an explicit decision.
1. Write locally first, and mark it.
@Entity
data class NoteEntity(
@PrimaryKey val id: String,
val text: String,
val syncState: SyncState, // SYNCED, PENDING, FAILED
val updatedAt: Long,
)
suspend fun saveNote(note: Note) {
dao.upsert(note.toEntity(syncState = SyncState.PENDING))
syncScheduler.enqueue(note.id)
}
The user sees their change on the next frame. Sync state lives on the row, which matters — see below.
2. Queue the sync so it survives everything.
WorkManager.getInstance(context).enqueueUniqueWork(
"sync-note-${note.id}",
ExistingWorkPolicy.REPLACE,
OneTimeWorkRequestBuilder<SyncNoteWorker>()
.setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED))
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build(),
)
The unique name is the important detail. Without it, editing a note five times offline queues five
workers that fight each other on reconnect. REPLACE means the latest edit wins locally, which is
almost always what the user meant.
3. Decide the conflict rule out loud.
This is the decision teams skip, and then discover in production. Your options, honestly stated:
- Last-write-wins by client timestamp — simple, and silently loses data when two devices edit. Acceptable for single-user, single-device-at-a-time data. Say so in a comment.
- Server wins — safe, and throws away the user's offline work. Only acceptable if you tell them.
- Merge per field — genuinely correct for things like a profile, more code, needs server support.
- Surface the conflict — the honest option for documents and notes; expensive UI.
There is no default that's right for everyone. What's wrong is not choosing, because then you get last-write-wins by accident with no comment explaining it.
4. Make failure visible.
A sync that fails silently is worse than an error, because the user believes their data is safe. A small "pending" or "not synced" affordance on the row costs little and prevents the worst support tickets.
The trap: a global isSyncing flag
// Don't do this
val isSyncing = MutableStateFlow(false)
Once a global sync flag exists, screens start reading it, spinners appear on unrelated data, and the flag desynchronises from reality the first time two syncs overlap.
Sync state belongs to the row, not to the app. The note that's pending should say so; the rest of the screen shouldn't care. If you need an aggregate for a settings screen, derive it from the rows:
fun pendingCount(): Flow<Int> = dao.countBySyncState(SyncState.PENDING)
Deletes need tombstones
The one case that catches everyone: deleting a row locally means there's nothing left to tell the server about. Mark deleted rather than removing:
@Query("UPDATE notes SET deleted = 1, syncState = 'PENDING' WHERE id = :id")
suspend fun markDeleted(id: String)
Filter deleted = 0 in your read queries, and purge tombstones once the server confirms.
What this buys you
An app that opens instantly with real content, works on a train, and doesn't show a spinner because a CDN in another country is slow. Those are all the same architectural decision, made once, at the repository boundary.
Offline-first isn't a feature you add near the end. It's a direction you point the data at the start — and pointing it the other way is what makes it feel impossible later.