Why Offline-First Is Not Optional
The Repository Pattern: Local-First Reads
class ArticleRepository @Inject constructor(
private val articleDao: ArticleDao,
private val api: ArticleApi,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) {
// UI observes this Flow -- always has data from Room
fun observeArticles(): Flow<List<Article>> =
articleDao.observeAll()
// Background refresh: fetch from network, save 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: Exception) {
Result.failure(e)
}
}
}
// ViewModel: observe local data, trigger refresh
@HiltViewModel
class ArticleListViewModel @Inject constructor(
private val repo: ArticleRepository
) : ViewModel() {
val articles = repo.observeArticles()
.stateIn(
viewModelScope,
SharingStarted.Lazily,
emptyList()
)
val isRefreshing = MutableStateFlow(false)
init { refresh() }
fun refresh() {
viewModelScope.launch {
isRefreshing.value = true
repo.refreshArticles()
isRefreshing.value = false
}
}
}Offline Writes: Queue and Sync
// Entity with sync tracking
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)
}
// Repository: write locally, queue for sync
class TaskRepository @Inject constructor(
private val dao: TaskDao,
private val api: TaskApi,
) {
// Writes go to Room immediately -- instant UI update
suspend fun createTask(title: String, desc: String) {
val task = TaskEntity(
title = title,
description = desc,
syncStatus = SyncStatus.PENDING,
)
dao.upsert(task)
// Sync happens via WorkManager
}
// Called by WorkManager sync job
suspend fun syncPendingTasks(): Result<Unit> {
val pending = dao.getPendingSync()
for (task in pending) {
try {
api.upsertTask(task.toApiModel())
dao.upsert(
task.copy(syncStatus = SyncStatus.SYNCED)
)
} catch (e: Exception) {
dao.upsert(
task.copy(syncStatus = SyncStatus.FAILED)
)
}
}
return Result.success(Unit)
}
}Background Sync with WorkManager
// Sync Worker
@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: Exception) {
if (runAttemptCount < 3) Result.retry()
else Result.failure()
}
}
}
// Schedule sync
object SyncScheduler {
fun schedulePeriodicSync(context: Context) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
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,
)
}
// Trigger immediate sync after a local write
fun triggerImmediateSync(context: Context) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val oneTimeSync =
OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(constraints)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(
"immediate-sync",
ExistingWorkPolicy.REPLACE,
oneTimeSync,
)
}
}Conflict Resolution Strategies
// Last-write-wins with server arbitration
class ConflictResolver {
fun resolveTask(
local: TaskEntity,
remote: TaskApiModel
): TaskEntity {
return if (local.lastModified > remote.lastModified) {
// Local is newer -- keep local, push to server
local.copy(syncStatus = SyncStatus.PENDING)
} else {
// Remote is newer -- accept remote
remote.toEntity().copy(
syncStatus = SyncStatus.SYNCED
)
}
}
}
// Field-level merge for richer conflict handling
fun fieldLevelMerge(
base: TaskEntity, // Last known synced version
local: TaskEntity, // Local changes
remote: TaskEntity, // Remote changes
): TaskEntity {
val mergedTitle = when {
local.title == base.title -> remote.title
remote.title == base.title -> local.title
local.title == remote.title -> local.title
else -> local.title // Both changed -- prefer local
}
val mergedCompleted = when {
local.isCompleted == base.isCompleted ->
remote.isCompleted
remote.isCompleted == base.isCompleted ->
local.isCompleted
else -> local.isCompleted
}
return local.copy(
title = mergedTitle,
isCompleted = mergedCompleted,
syncStatus = SyncStatus.PENDING,
)
}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)
}
// Subtle sync status indicator
when (task.syncStatus) {
SyncStatus.PENDING -> Icon(
Icons.Outlined.CloudUpload,
contentDescription = "Pending sync",
tint = MaterialTheme.colorScheme.outline,
modifier = Modifier.size(16.dp),
)
SyncStatus.FAILED -> Icon(
Icons.Outlined.CloudOff,
contentDescription = "Sync failed",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp),
)
SyncStatus.SYNCED -> { /* No indicator */ }
}
}
}Key Takeaways
- 1Offline-first means always read from and write to Room -- the network is a sync channel, not a prerequisite.
- 2The repository pattern coordinates local storage and remote API with Room Flow for reactive UI.
- 3Track pending changes with a syncStatus column on your Room entities.
- 4Use WorkManager for background sync with network constraints and exponential backoff.
- 5Last-write-wins is the simplest conflict resolution; field-level merge preserves more data.
- 6Show subtle sync indicators so users know their data is safe without overwhelming them.
Frequently Asked
How do I handle offline writes?
Store writes in database with pending flag. Use WorkManager to sync pending writes when online. Handle conflicts server-side or with timestamp comparison.
When should I go offline-first?
Always, unless your app is purely a real-time dashboard. Users expect apps to work in elevators, subways, and airplanes. Offline-first improves perceived performance even on good networks.
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.