Offline-first means your app is useful even when the internet is slow, unstable, or completely unavailable.
For most Android apps, this is the practical approach:
- Read from local database (Room) first
- Sync with network in background
- Resolve conflicts predictably
If you skip these rules, users see loading spinners, stale UI, and random overwrites.
1) The core mental model
Think in this order:
- Room is the source of truth for UI
- Network is a source of updates
- Repository coordinates both
Your Compose screen should observe Room (usually via Flow) and recompose automatically when sync writes new data.
2) BAD vs GOOD #1 — Fetch-directly-from-network UI
❌ BAD: ViewModel depends on network call for screen state
// BAD: UI depends on live network every time.
// If device is offline, the screen fails.
class TasksViewModel(
private val api: TasksApi
) : ViewModel() {
private val _state = MutableStateFlow(TasksUiState())
val state: StateFlow<TasksUiState> = _state
fun load() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
// BAD: direct network dependency for rendering.
val remoteTasks = api.getTasks()
_state.value = TasksUiState(
isLoading = false,
tasks = remoteTasks,
error = null
)
}
}
}Why this hurts:
- No data when offline
- Screen state disappears on process death unless manually cached
- You duplicate cache logic in multiple ViewModels
✅ GOOD: ViewModel observes Room, repository syncs in background
Let’s expand this as a complete beginner-friendly chain.
The goal is simple:
- UI reads from Room only (stable offline behavior)
- Repository refreshes Room from network (in background)
- ViewModel does not render network responses directly
So even if API fails, UI can still show the last local snapshot.
Step A — Define remote API model (TasksApi)
// Remote DTO returned by backend.
// Keep DTO in data/remote layer (do not expose directly to UI).
data class TaskDto(
val id: String,
val title: String,
val completed: Boolean,
val updatedAtEpochMs: Long,
)
interface TasksApi {
@GET("/tasks")
suspend fun getTasks(): List<TaskDto>
@PUT("/tasks/{id}")
suspend fun updateTask(
@Path("id") id: String,
@Body body: TaskDto,
)
}Step B — Define Room entity + DAO (TasksDao)
@Entity(tableName = "tasks")
data class TaskEntity(
@PrimaryKey val id: String,
val title: String,
val completed: Boolean,
val updatedAtEpochMs: Long,
)
@Dao
interface TasksDao {
// Flow = UI gets updates automatically when table changes.
@Query("SELECT * FROM tasks ORDER BY updatedAtEpochMs DESC")
fun observeAll(): Flow<List<TaskEntity>>
// For conflict/sync decisions (used later in advanced sync logic).
@Query("SELECT * FROM tasks WHERE id = :id LIMIT 1")
suspend fun findById(id: String): TaskEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(task: TaskEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(tasks: List<TaskEntity>)
@Query("DELETE FROM tasks")
suspend fun deleteAll()
}Step C — Mapping between layers
// Domain model consumed by UI/ViewModel.
data class Task(
val id: String,
val title: String,
val completed: Boolean,
)
fun TaskEntity.toDomain(): Task = Task(
id = id,
title = title,
completed = completed,
)
fun TaskDto.toEntity(): TaskEntity = TaskEntity(
id = id,
title = title,
completed = completed,
updatedAtEpochMs = updatedAtEpochMs,
)Step D — Repository: local stream + background refresh
class TasksRepository(
private val dao: TasksDao,
private val api: TasksApi,
) {
// UI-friendly stream comes from Room, not from Retrofit.
fun observeTasks(): Flow<List<Task>> =
dao.observeAll().map { entities -> entities.map { it.toDomain() } }
suspend fun refreshTasks() {
// 1) Pull latest from server
val remoteTasks = api.getTasks()
// 2) Write to DB
dao.upsertAll(remoteTasks.map { it.toEntity() })
// 3) No direct callback to UI needed.
// Room flow emits automatically -> ViewModel state updates.
}
}Step E — Use cases keep ViewModel focused
class ObserveTasksUseCase(
private val repository: TasksRepository
) {
operator fun invoke(): Flow<List<Task>> = repository.observeTasks()
}
class RefreshTasksUseCase(
private val repository: TasksRepository
) {
suspend operator fun invoke() = repository.refreshTasks()
}Step F — ViewModel orchestrates state + refresh intent
data class TasksUiState(
val isLoading: Boolean = false,
val tasks: List<Task> = emptyList(),
val error: String? = null,
)
class TasksViewModel(
private val observeTasks: ObserveTasksUseCase,
private val refreshTasks: RefreshTasksUseCase,
) : ViewModel() {
private val refreshState = MutableStateFlow(false)
private val refreshError = MutableStateFlow<String?>(null)
// Combine local tasks with transient refresh info.
val uiState: StateFlow<TasksUiState> = combine(
observeTasks(),
refreshState,
refreshError,
) { tasks, isRefreshing, error ->
TasksUiState(
isLoading = isRefreshing && tasks.isEmpty(),
tasks = tasks,
error = error,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = TasksUiState(isLoading = true),
)
init {
// Initial sync attempt; UI still renders local data if present.
onPullToRefresh()
}
fun onPullToRefresh() {
viewModelScope.launch {
refreshState.value = true
refreshError.value = null
runCatching { refreshTasks() }
.onFailure { throwable ->
// Important: keep UI alive with local data; only surface sync error.
refreshError.value = throwable.message ?: "Sync failed"
}
refreshState.value = false
}
}
}Why this architecture helps beginners
- You always know where to look:
- API issues ->
TasksApi/ network layer - DB stream issues ->
TasksDao - sync rules -> repository/use case
- rendering issues -> composable/UI state
- API issues ->
- UI does not break offline because it does not depend on immediate network response.
- Room + Flow gives predictable recomposition behavior.
3) BAD vs GOOD #2 — Last-write-wins with no conflict policy
When users can edit data offline, conflicts are guaranteed.
❌ BAD: blindly overwrite local with server payload
// BAD: server data fully replaces local rows.
// Local unsynced edits may be lost.
suspend fun naiveSyncTask(taskId: String) {
val remote = api.getTask(taskId)
// BAD: no check for local pending changes.
// BAD: no versioning or timestamp comparison.
dao.insert(remote.toEntity())
}This creates “Where did my change go?” moments.
✅ GOOD: explicit conflict metadata + deterministic resolver
// GOOD: Store metadata needed for conflict resolution.
// pendingSync=true means local edit has not been accepted by server yet.
@Entity(tableName = "tasks")
data class TaskEntity(
@PrimaryKey val id: String,
val title: String,
val completed: Boolean,
val updatedAtEpochMs: Long,
val pendingSync: Boolean,
val version: Long
)// GOOD: Resolve conflict with clear business rule.
// Example policy:
// 1) If local has pending sync, keep local and queue upload.
// 2) Otherwise prefer the newest update timestamp.
fun resolveTask(local: TaskEntity?, remote: TaskDto): TaskEntity {
val remoteEntity = remote.toEntity()
if (local == null) return remoteEntity.copy(pendingSync = false)
if (local.pendingSync) {
// Keep local unsynced edit; avoid accidental overwrite.
return local
}
return if (remoteEntity.updatedAtEpochMs >= local.updatedAtEpochMs) {
remoteEntity.copy(pendingSync = false)
} else {
local
}
}// GOOD: Sync loop applies resolver row-by-row.
suspend fun syncTasks() {
val remoteTasks = api.getTasks()
remoteTasks.forEach { remote ->
val local = dao.findById(remote.id)
val resolved = resolveTask(local, remote)
dao.upsert(resolved)
}
// Then upload local pending edits in a second phase.
val pending = dao.pendingSyncTasks()
pending.forEach { localTask ->
api.updateTask(localTask.toDto())
dao.markSynced(localTask.id)
}
}4) Practical implementation checklist
- Add
pendingSync,updatedAt, and optionallyversioncolumns - Use WorkManager for retryable background sync
- Keep API errors out of rendering path (UI reads local DB)
- Define one conflict policy per entity and document it
- Add tests for sync + conflict rules (most bugs live here)
Key Takeaway: Offline-first is not “cache plus hope.” Make Room your UI source of truth, sync in background, and define deterministic conflict rules. Predictability beats cleverness when users edit data offline.