Room and Coroutines: Offline-First Data in a Compose App
The write path, the read path, and what to do when the network comes back. One decision drives all of it: the database is the source of truth.
Offline-first is one decision, not a feature: the database is the source of truth, and the network is something that updates it. Every hard part of this follows from taking that literally.
Most apps get it backwards. The screen calls the API, shows a spinner, and writes to Room as a cache afterwards. That app works beautifully on office wifi and falls apart on a train.
The read path: the UI never sees the network
Your DAO returns a Flow. The ViewModel maps it. The screen collects it. Nothing in that chain knows whether the device is online.
@Dao
interface DebtDao {
// Rows pending deletion stay in the table until the server
// confirms, but must not appear in the UI.
@Query("select * from debts where sync_state != 'PENDING_DELETE' order by due_at")
fun observeAll(): Flow<List<DebtEntity>>
@Upsert
suspend fun upsert(debt: DebtEntity)
@Query("select * from debts where sync_state != 'SYNCED'")
suspend fun pending(): List<DebtEntity>
}val debts: StateFlow<List<Debt>> = dao.observeAll()
.map { rows -> rows.map(DebtEntity::toDomain) }
.stateIn(
scope = viewModelScope,
// The 5s matters: it keeps the flow alive across a rotation
// so you do not re-query on every configuration change.
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList(),
)The write path: local first, always
A write commits to Room immediately and marks the row as pending. The UI updates from the flow — no spinner, no optimistic-update bookkeeping, because the local write is the update.
suspend fun addDebt(debt: Debt) {
dao.upsert(debt.toEntity(syncState = SyncState.PENDING_CREATE))
// Ask for a sync. Whether one happens now, in ten minutes,
// or after a reboot is WorkManager's problem, not this function's.
syncScheduler.requestSync()
}Note what this function does not do: no network call, no try/catch around a timeout, no rollback path. Adding a debt on a plane is the same code path as adding one on wifi.
The sync worker drains a queue
override suspend fun doWork(): Result {
val pending = dao.pending()
for (row in pending) {
val outcome = runCatching {
when (row.syncState) {
SyncState.PENDING_CREATE, SyncState.PENDING_UPDATE ->
api.upsert(row.toDto())
SyncState.PENDING_DELETE -> api.delete(row.id)
SyncState.SYNCED -> return@runCatching
}
}
if (outcome.isFailure) {
// Leave the row pending and let WorkManager back off.
// Do not drop the change on the floor.
return Result.retry()
}
when (row.syncState) {
SyncState.PENDING_DELETE -> dao.deleteHard(row.id)
else -> dao.upsert(row.copy(syncState = SyncState.SYNCED))
}
}
return Result.success()
}Register it with a network constraint so it does not wake up to fail:
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build(),
)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
"debt-sync",
// KEEP, not REPLACE — a sync already in flight should finish.
ExistingWorkPolicy.KEEP,
request,
)Ids have to be generated on the device
This is the detail that decides whether the whole design holds. If the server assigns the primary key, a row created offline has no id, so nothing else can reference it until it syncs — and a user who creates a debt and immediately adds a payment to it is now blocked on the network.
Generate a UUID client-side and let the server accept it. Creation becomes idempotent for free: a retry after a timeout that actually succeeded upserts the same id instead of creating a duplicate.
Decide your conflict rule before you need it
Two devices edit the same row offline. Whatever you do is a policy decision, so make it deliberately: last-write-wins on a server timestamp is the simplest and is correct for single-user data like this; server-authority is safer for anything shared; per-field merging is rarely worth the complexity outside collaborative editing.
Whichever you pick, write it in a comment next to the sync worker. The person debugging a "my edit disappeared" report in eight months will be you.
LoanDebtManager — $11
Built offline-first on exactly this shape — Room as the source of truth, a sync state column, and a worker that drains the queue.