Why Offline-First Is Not Optional
The Repository Pattern: Local-First Reads
Flow. A refresh fetches from the network and writes to Room, and the screen updates because the query it observes changed. The network never hands data to the UI directly.
Catch the failures you expect, IOException and HttpException. The earlier sample caught Exception, which also swallows coroutine cancellation and reports a cancelled refresh as a failed one. The rule and the reason are in the coroutines deep dive.
A long list read this way should be paged, with the database still in the middle. That is what RemoteMediator is for, covered in the Paging 3 guide.class ArticleRepository @Inject constructor(
private val articleDao: ArticleDao,
private val api: ArticleApi,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) {
// The UI observes Room and nothing else
fun observeArticles(): Flow<List<Article>> =
articleDao.observeAll()
// Refresh: fetch from the network, write to Room
suspend fun refreshArticles(): Result<Unit> =
withContext(ioDispatcher) {
try {
val remote = api.getArticles()
articleDao.upsertAll(remote.map { it.toEntity() })
Result.success(Unit)
} catch (e: IOException) {
Result.failure(e)
} catch (e: HttpException) {
Result.failure(e)
}
}
}
@HiltViewModel
class ArticleListViewModel @Inject constructor(
private val repo: ArticleRepository
) : ViewModel() {
val articles = repo.observeArticles()
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
emptyList()
)
val isRefreshing = MutableStateFlow(false)
init { refresh() }
fun refresh() {
viewModelScope.launch {
isRefreshing.value = true
try {
repo.refreshArticles()
} finally {
isRefreshing.value = false
}
}
}
}How should an offline-first app handle writes?
| Strategy | What it does | Use it for |
|---|---|---|
| Online-only writes | Writes across the network first and updates local data only on success | Transactions that must happen online, such as a bank transfer |
| Queued writes | Puts the object in a queue and drains it when the app is back online | Analytics events and logging |
| Lazy writes | Writes locally first, then queues the write for the network | Data that is critical to the app |
PENDING. A sync job pushes pending rows and marks them SYNCED.
The earlier sync function had the two defects mentioned above. It caught every exception and marked the row FAILED, and its query only selected PENDING rows, so a write that failed once because the train entered a tunnel was never sent again. It also returned success no matter what happened, so the worker's retry branch could not run. The version below separates the two kinds of failure. A network error is rethrown, the row stays PENDING, and WorkManager retries. A request the server rejects is marked FAILED, because sending it again will not change the answer.enum class SyncStatus { SYNCED, PENDING, FAILED }
@Entity(tableName = "tasks")
data class TaskEntity(
@PrimaryKey
val id: String = UUID.randomUUID().toString(),
val title: String,
val description: String,
val isCompleted: Boolean = false,
val syncStatus: SyncStatus = SyncStatus.PENDING,
val lastModified: Long = System.currentTimeMillis(),
)
@Dao
interface TaskDao {
@Query("SELECT * FROM tasks ORDER BY lastModified DESC")
fun observeAll(): Flow<List<TaskEntity>>
@Query("SELECT * FROM tasks WHERE syncStatus = 'PENDING'")
suspend fun getPendingSync(): List<TaskEntity>
@Upsert
suspend fun upsert(task: TaskEntity)
}
class TaskRepository @Inject constructor(
private val dao: TaskDao,
private val api: TaskApi,
private val syncScheduler: SyncScheduler,
) {
// Lazy write: local first, then queue the network write
suspend fun createTask(title: String, desc: String) {
dao.upsert(
TaskEntity(
title = title,
description = desc,
syncStatus = SyncStatus.PENDING,
)
)
syncScheduler.requestSync()
}
// Called by the sync worker.
// Throws IOException so the worker can retry; the row stays PENDING.
suspend fun syncPendingTasks() {
for (task in dao.getPendingSync()) {
try {
api.upsertTask(task.toApiModel())
dao.upsert(task.copy(syncStatus = SyncStatus.SYNCED))
} catch (e: HttpException) {
if (e.code() in 400..499) {
// Rejected by the server: retrying will not help
dao.upsert(task.copy(syncStatus = SyncStatus.FAILED))
} else {
throw IOException("Server error ${e.code()}", e)
}
}
}
}
}Background Sync with WorkManager
ExistingWorkPolicy.REPLACE. The reference says what that does to work already in progress: "cancel and delete it" (Android). Every new write cancelled the upload of the previous ones. KEEP has the opposite flaw: a write that lands while a sync is running is ignored until the next periodic run. APPEND_OR_REPLACE queues the new request behind the running one, so nothing is cancelled and nothing is skipped.
Keep one startup or periodic sync. For that the guide's own sample, from the Now in Android app, enqueues unique work with KEEP, so that only one sync worker runs at a time.@HiltWorker
class SyncWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val taskRepo: TaskRepository,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
return try {
taskRepo.syncPendingTasks()
Result.success()
} catch (e: IOException) {
// Connectivity or server trouble: back off and try again
if (runAttemptCount < MAX_ATTEMPTS) Result.retry()
else Result.failure()
}
}
private companion object {
const val MAX_ATTEMPTS = 5
}
}
class SyncScheduler @Inject constructor(
@ApplicationContext private val context: Context
) {
private val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
fun schedulePeriodicSync() {
val periodicSync =
PeriodicWorkRequestBuilder<SyncWorker>(
30, TimeUnit.MINUTES,
5, TimeUnit.MINUTES // flex interval
)
.setConstraints(constraints)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
1, TimeUnit.MINUTES
)
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork(
"periodic-sync",
ExistingPeriodicWorkPolicy.KEEP,
periodicSync,
)
}
// After a local write: runs when connected, behind any sync in flight
fun requestSync() {
val oneTimeSync =
OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(constraints)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(
"write-sync",
ExistingWorkPolicy.APPEND_OR_REPLACE,
oneTimeSync,
)
}
}Conflict Resolution Strategies
// The device sends its timestamp and applies what comes back
suspend fun pushTask(local: TaskEntity) {
// The server compares lastModified with its own state and
// returns the version that won.
val winner: TaskApiModel = api.upsertTask(local.toApiModel())
dao.upsert(
winner.toEntity().copy(syncStatus = SyncStatus.SYNCED)
)
}
// Field-level merge, as server-side logic
fun fieldLevelMerge(
base: TaskEntity, // Last version both sides agreed on
local: TaskEntity, // Edit from device A
remote: TaskEntity, // Edit from device B
): TaskEntity {
val mergedTitle = when {
local.title == base.title -> remote.title // Only B changed it
remote.title == base.title -> local.title // Only A changed it
else -> remote.title // Both changed it: later write wins
}
val mergedCompleted = when {
local.isCompleted == base.isCompleted -> remote.isCompleted
remote.isCompleted == base.isCompleted -> local.isCompleted
else -> remote.isCompleted
}
return base.copy(
title = mergedTitle,
isCompleted = mergedCompleted,
)
}Showing Sync Status in the UI
@Composable
fun TaskItem(
task: TaskEntity,
onToggleComplete: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = task.isCompleted,
onCheckedChange = { onToggleComplete() },
)
Column(modifier = Modifier.weight(1f)) {
Text(task.title,
style = MaterialTheme.typography.bodyLarge)
Text(task.description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme
.onSurfaceVariant)
}
when (task.syncStatus) {
SyncStatus.PENDING -> Icon(
Icons.Outlined.CloudUpload,
contentDescription = "Waiting to sync",
tint = MaterialTheme.colorScheme.outline,
modifier = Modifier.size(16.dp),
)
SyncStatus.FAILED -> Icon(
Icons.Outlined.CloudOff,
contentDescription = "Sync failed, needs attention",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp),
)
SyncStatus.SYNCED -> { /* No indicator */ }
}
}
}Key Takeaways
- 1The local data source is the source of truth. The screen reads Room and nothing else.
- 2Android documents three write strategies. Writing locally first and queueing the network write is a lazy write, the right choice for critical data.
- 3A network error must leave the row pending and let the worker retry. Only a rejected request is a permanent failure.
- 4ExistingWorkPolicy.REPLACE cancels a sync already in flight. Use APPEND_OR_REPLACE for a sync requested after each write.
- 5With last write wins, the device attaches the timestamp and the network data source decides.
- 6Show pending quietly and failed loudly, and show nothing when everything is synced.
Frequently Asked
How do I handle writes made while offline?
Write to the local database first and mark the row as pending, then queue a WorkManager job that needs a network connection. When it runs, push the pending rows and mark them synced. Leave a row pending after a network error so it is retried, and mark it failed only when the server rejects it.
When should an app be offline-first?
When it has core functions people need without a connection. Android defines an offline-first app as one that can perform all, or a critical subset, of its core functionality without internet access. A transaction that must happen online, such as a bank transfer, stays an online-only write even inside an offline-first app.
Who should resolve a sync conflict, the device or the server?
The server. In the last write wins approach Android describes, devices attach a timestamp to what they write and the network data source discards anything older than its current state. A device that compares timestamps locally can decide it won while another device decides the same thing about its own copy.
Ready to architect your next Android app?
ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.