Offline-first means the app works fully without a network connection, and syncs automatically when connectivity returns. It's a design principle, not a feature — it requires decisions at every layer.
The Core Principle
Read from local. Write to local. Sync in background.
The user never waits for the network. Every action is instant because it touches local storage first. The network is an implementation detail.
Architecture Layers
UI → ViewModel → Repository
├── Local DB (Room) — single source of truth
└── Remote (API) — synced in background
The repository always reads from Room. It never reads directly from the API in the happy path. The API is only involved during sync.
Offline Reads
Simple: Room is always the source. The cache-first pattern from other lessons applies here directly.
fun getArticles(): Flow<List<Article>> =
dao.getAllAsFlow() // ← always Room
.map { entities -> entities.map { it.toDomain() } }
Offline Writes: the Outbox Pattern
When the user makes a change while offline, write it locally and queue it for sync:
@Entity(tableName = "outbox")
data class PendingOperation(
@PrimaryKey(autoGenerate = true) val localId: Long = 0,
val type: String, // "create_article", "update_article", "delete_article"
val entityId: String,
val payload: String, // JSON-serialized request body
val createdAt: Long = System.currentTimeMillis(),
val retryCount: Int = 0
)
@Dao
interface OutboxDao {
@Insert suspend fun enqueue(op: PendingOperation): Long
@Query("SELECT * FROM outbox ORDER BY createdAt ASC") suspend fun getAll(): List<PendingOperation>
@Delete suspend fun remove(op: PendingOperation)
@Update suspend fun update(op: PendingOperation)
}
// In repository: user creates an article
suspend fun createArticle(article: Article) {
// 1. Write optimistically to local DB
dao.insert(article.toEntity().copy(syncState = SyncState.PENDING))
// 2. Enqueue for sync
outboxDao.enqueue(PendingOperation(
type = "create_article",
entityId = article.id,
payload = json.encodeToString(article)
))
}
Sync Worker
class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val operations = outboxDao.getAll()
var allSucceeded = true
for (op in operations) {
try {
when (op.type) {
"create_article" -> {
val article = json.decodeFromString<Article>(op.payload)
val serverArticle = api.createArticle(article)
// Replace local placeholder with server response
dao.insert(serverArticle.toEntity().copy(syncState = SyncState.SYNCED))
outboxDao.remove(op)
}
"delete_article" -> {
api.deleteArticle(op.entityId)
outboxDao.remove(op)
}
}
} catch (e: IOException) {
// Network error — leave in outbox, retry later
allSucceeded = false
if (op.retryCount >= 5) {
// Give up on this operation
outboxDao.remove(op)
markSyncFailed(op.entityId)
} else {
outboxDao.update(op.copy(retryCount = op.retryCount + 1))
}
}
}
return if (allSucceeded) Result.success() else Result.retry()
}
}
Triggering Sync
Sync on connectivity change using WorkManager constraints:
fun scheduleSync() {
val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
"sync",
ExistingWorkPolicy.KEEP, // don't start a second sync if one is running
syncRequest
)
}
Conflict Resolution
When local and remote data diverge, you need a merge strategy:
| Strategy | Description | Use when |
|---|---|---|
| Last-write-wins (timestamp) | Latest updatedAt wins | Single-user data |
| Server wins | Discard local changes | Content created server-side |
| Client wins | Push local changes regardless | User-generated content |
| Merge | Field-level merge by operational transform | Collaborative editing |
UI: Communicate Sync State
enum class SyncState { SYNCED, PENDING, FAILED }
// In ViewModel:
val articles: Flow<List<ArticleUiState>> = dao.getAllAsFlow()
.map { entities ->
entities.map { entity ->
ArticleUiState(
article = entity.toDomain(),
isPending = entity.syncState == SyncState.PENDING,
isFailed = entity.syncState == SyncState.FAILED
)
}
}
Show a subtle indicator for pending items. Show a retry option for failed ones. Don't block the UI on sync status.