An offline-first app works fully without a network connection and syncs when connectivity returns. This is not just about caching — it requires deliberate decisions about the source of truth, write strategies, conflict resolution, and background sync.
Users on unreliable connections (commutes, travel, low-signal areas) notice immediately when apps fail gracefully instead of showing error screens.
Single Source of Truth
The defining principle of offline-first: the local database is the source of truth. The UI never reads directly from the network. The UI observes the database, and the database is populated by sync operations.
Network → Repository → Room (source of truth) → ViewModel → UI
The flow is one-directional. The UI cannot get into a state where it shows stale network data on one screen and fresh database data on another.
class TaskRepository(
private val api: TaskApi,
private val dao: TaskDao
) {
// UI observes this — always from Room
fun observeTasks(): Flow<List<Task>> =
dao.observeTasks().map { it.map(TaskEntity::toTask) }
// Sync writes to Room; UI updates automatically via Flow
suspend fun sync(): Result<Unit> = runCatching {
val remoteTasks = api.getTasks()
dao.upsertAll(remoteTasks.map(TaskDto::toEntity))
}
}
Optimistic Updates
An optimistic update applies a change to the local database immediately — before the network confirms success. If the network call fails, the change is rolled back.
This makes the UI feel instant:
suspend fun completeTask(taskId: String): Result<Unit> {
// 1. Update locally — UI sees the change immediately
dao.setCompleted(taskId, completed = true)
// 2. Sync to server
return runCatching {
api.completeTask(taskId)
}.onFailure {
// 3. Rollback on failure
dao.setCompleted(taskId, completed = false)
}
}
Add a syncStatus field to your entity to track pending writes:
@Entity
data class TaskEntity(
@PrimaryKey val id: String,
val title: String,
val completed: Boolean,
val syncStatus: SyncStatus = SyncStatus.SYNCED
)
enum class SyncStatus { SYNCED, PENDING, FAILED }
When the device comes back online, find all PENDING entities and sync them.
Background Sync with WorkManager
WorkManager schedules deferrable, guaranteed background work. It survives app restarts and device reboots.
class SyncWorker(
context: Context,
params: WorkerParameters,
private val repository: TaskRepository
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
return try {
repository.sync().fold(
onSuccess = { Result.success() },
onFailure = { Result.retry() }
)
} catch (e: Exception) {
Result.failure()
}
}
}
Schedule periodic sync:
val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(15, TimeUnit.MINUTES)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork(
"task_sync",
ExistingPeriodicWorkPolicy.KEEP,
syncRequest
)
ExistingPeriodicWorkPolicy.KEEP prevents duplicate sync workers when the app is launched multiple times.
Paging with RemoteMediator
For paginated data (feeds, search results, history), use Paging 3's RemoteMediator. It fetches from the network, writes to Room, and the PagingSource reads from Room — the UI never touches the network directly.
@OptIn(ExperimentalPagingApi::class)
val pager = Pager(
config = PagingConfig(pageSize = 20),
remoteMediator = FeedRemoteMediator(api, db)
) {
db.postDao().pagingSource()
}
val posts: Flow<PagingData<Post>> = pager.flow
.map { pagingData -> pagingData.map(PostEntity::toPost) }
.cachedIn(viewModelScope)
cachedIn(viewModelScope) keeps the paged data alive across recompositions without re-fetching.
Conflict Resolution
When the same record is modified locally and on the server before sync, there is a conflict. Common strategies:
Last-write-wins: The record with the later updatedAt timestamp overwrites the other. Simple to implement, may lose data.
fun mergeTask(local: TaskEntity, remote: TaskDto): TaskEntity {
return if (remote.updatedAt > local.updatedAt) {
remote.toEntity()
} else {
local
}
}
Server wins: Remote always overrides local. Safest for multi-device scenarios where the server has the full picture.
Client wins: Local changes always take precedence. Useful when offline edits are the primary use case (note-taking apps).
Field-level merge: Different fields can come from different sources. Complex but most preserves data. Requires careful schema design.
Choose a strategy early and document it. Changing conflict resolution after launch requires a data migration.
Observing Connectivity
React when connectivity changes to trigger sync or show an offline banner:
@Singleton
class NetworkMonitor @Inject constructor(
context: Context
) {
val isOnline: Flow<Boolean> = callbackFlow {
val manager = context.getSystemService<ConnectivityManager>()!!
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { trySend(true) }
override fun onLost(network: Network) { trySend(false) }
}
manager.registerDefaultNetworkCallback(callback)
// Emit current state immediately
trySend(manager.activeNetwork != null)
awaitClose { manager.unregisterNetworkCallback(callback) }
}.distinctUntilChanged()
}
In the ViewModel, combine connectivity with UI state to show an offline indicator without blocking the UI:
val uiState: StateFlow<TasksUiState> = combine(
repository.observeTasks(),
networkMonitor.isOnline
) { tasks, isOnline ->
TasksUiState(tasks = tasks, isOffline = !isOnline)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TasksUiState())
Common Pitfalls
| Pitfall | Fix |
|---|---|
| Reading from network directly in ViewModel | Always go through Room as the source of truth |
| No sync status on entities | Add SyncStatus field to track pending writes |
| Sync runs on the main thread | Use CoroutineWorker or withContext(Dispatchers.IO) |
| No backoff on sync failures | Use WorkManager's BackoffPolicy.EXPONENTIAL |
| Overwriting local writes with stale server data | Compare timestamps before overwriting |
Practice
Build a notes app that:
- Shows notes from Room (always from local DB)
- Allows creating notes while offline (stored with
PENDINGstatus) - Syncs
PENDINGnotes with WorkManager when connectivity returns - Shows an offline banner using
NetworkMonitor - Uses
REPLACEconflict strategy (server wins) on sync
Summary
Offline-first means the local database is the source of truth and the UI always reads from it. Apply optimistic updates for instant feedback. Use WorkManager with exponential backoff for reliable background sync. Handle conflicts explicitly — last-write-wins works for most cases. Monitor connectivity and show meaningful UI, not just error screens.