Building Offline-First Android Apps with Room, Kotlin Flow, and WorkManager
Modern mobile users have zero tolerance for loading spinners when network connectivity drops in transit or elevators. In traditional "network-first" apps, the UI waits for an HTTP response before updating screen state. If the connection fails, the user sees an error dialog.
In an Offline-First Architecture, the paradigm is inverted:
- The local database (Room) is the Single Source of Truth.
- The UI observes database tables reactively via Kotlin Flow.
- All user actions mutate the local database immediately for instant UI feedback.
- Background sync jobs (via WorkManager) synchronize local modifications with the remote backend when the network is available.
Offline-First Data Flow
[User Action]
|
v
[Repository]
|--> 1. Write immediately to Local Room DB (Instant UI Update via Flow!)
+--> 2. Enqueue Background Sync Task (WorkManager)
|
v (When Network Connected)
[Remote Backend API]
|
v
[Fetch Delta & Update Room]
The user never experiences network latency or blocked interactions.
1. The Entity and Room DAO
Every entity includes synchronization metadata: a syncStatus flag (SYNCED, PENDING).
package com.devto.offlinefirst.data
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
enum class SyncStatus { SYNCED, PENDING }
@Entity(tableName = "notes")
data class NoteEntity(
@PrimaryKey val id: String,
val title: String,
val content: String,
val updatedAt: Long,
val syncStatus: SyncStatus = SyncStatus.PENDING
)
@Dao
interface NoteDao {
@Query("SELECT * FROM notes ORDER BY updatedAt DESC")
fun observeAllNotes(): Flow<List<NoteEntity>>
@Query("SELECT * FROM notes WHERE syncStatus = 'PENDING'")
suspend fun getPendingNotes(): List<NoteEntity>
@Upsert
suspend fun upsertNote(note: NoteEntity)
@Query("UPDATE notes SET syncStatus = 'SYNCED' WHERE id = :noteId")
suspend fun markSynced(noteId: String)
}
2. The Repository: Decoupling UI from Network
package com.devto.offlinefirst.data
import androidx.work.*
import kotlinx.coroutines.flow.Flow
import java.util.UUID
class NoteRepository(
private val noteDao: NoteDao,
private val workManager: WorkManager
) {
val notesStream: Flow<List<NoteEntity>> = noteDao.observeAllNotes()
suspend fun createNote(title: String, content: String) {
val newNote = NoteEntity(
id = UUID.randomUUID().toString(),
title = title,
content = content,
updatedAt = System.currentTimeMillis(),
syncStatus = SyncStatus.PENDING
)
// 1. Instant local write - screen updates immediately!
noteDao.upsertNote(newNote)
// 2. Schedule background sync with network constraint
scheduleSyncWork()
}
private fun scheduleSyncWork() {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val syncRequest = OneTimeWorkRequestBuilder<SyncNotesWorker>()
.setConstraints(constraints)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
15,
java.util.concurrent.TimeUnit.SECONDS
)
.build()
workManager.enqueueUniqueWork(
"SyncNotesWork",
ExistingWorkPolicy.APPEND_OR_REPLACE,
syncRequest
)
}
}
Key Architectural Guidelines
- Never perform network calls directly in the ViewModel: ViewModels should only observe domain streams and call repository mutation methods.
- Handle Conflict Resolution: Choose a clear strategy: Last-Write-Wins (timestamp comparison) or server-side three-way merging.
- Optimistic UI by Default: Assume operations will succeed locally. Never show a blocking dialog for standard data entry.
Comments
No comments yet. Start the discussion.