Introduction
Mobile connectivity isn’t “good” or “bad” — it’s inconsistent. Shopping malls, subway tunnels, packed airports, national park campsites, and long rural interstate stretches… your users live in all of it.
If your app’s main flow is “call API → render UI”, then every timeout turns into:
- spinners,
- retry buttons,
- and users losing trust.
Offline-first changes the contract: your UI stays functional even when the network doesn’t. The network becomes a sync channel, not a dependency for basic UX.
In this post, we’ll build an offline-first shape you can ship:
- Room as UI source of truth
- Reads via stale-while-revalidate (SWR)
- Writes via the Outbox pattern
- Background sync via WorkManager
The mental model
Online-first (fragile)
If the API fails, the screen fails.
Offline-first (resilient)
Room is what the UI trusts.
The network is what eventually makes your local state match the server.
Core patterns: SWR + Outbox
1) Reads: Stale-While-Revalidate (SWR)
- UI renders immediately from Room
- Repository decides if data is “old enough” to refresh
- Refresh runs in background
- When Room updates, the UI updates (because it’s collecting a Flow)
2) Writes: Outbox pattern
- Save user edits to Room immediately
- Mark records as PENDING
- Append an operation to an outbox table
- WorkManager uploads when connectivity allows
- On success, mark as SYNCED
- On conflict, mark as CONFLICTED (or apply your merge rule)
Data model: domain + sync metadata
We’ll use different names throughout:
-
Clip= the thing users view/edit -
VaultDb= Room database -
CourierApi= Retrofit API -
DispatchSyncWorker= WorkManager worker
Sync state enums
enum class SyncFlag { SYNCED, PENDING, SYNCING, CONFLICTED }
enum class OpKind { UPSERT, DELETE }
Room entity: ClipEntity
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "clips")
data class ClipEntity(
@PrimaryKey val clipId: String, // client-generated UUID
val title: String,
val body: String,
// sync metadata
val localUpdatedAt: Long, // device time when user edited
val serverUpdatedAt: Long?, // server timestamp if known
val cachedAt: Long, // last successful fetch time
val lastOpenedAt: Long, // for cleanup / LRU
val syncFlag: SyncFlag // SYNCED / PENDING / ...
)
Outbox: OutboxOp (queued operations)
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "outbox_ops")
data class OutboxOp(
@PrimaryKey val opId: String, // UUID
val clipId: String,
val kind: OpKind, // UPSERT / DELETE
val payloadJson: String?, // null for delete
val sequence: Long, // ordering per clip
val attempts: Int,
val lastAttemptAt: Long?
)
DAO: Flows for UI + queries for sync
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Dao
interface ClipDao {
// UI reads
@Query("SELECT * FROM clips ORDER BY lastOpenedAt DESC")
fun observeClips(): Flow<List<ClipEntity>>
@Query("SELECT * FROM clips WHERE clipId = :id LIMIT 1")
fun observeClip(id: String): Flow<ClipEntity?>
// Writes
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertClip(item: ClipEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertClips(items: List<ClipEntity>)
// Outbox
@Query("SELECT * FROM outbox_ops ORDER BY clipId ASC, sequence ASC")
suspend fun loadOutboxOrdered(): List<OutboxOp>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun enqueueOutbox(op: OutboxOp)
@Query("DELETE FROM outbox_ops WHERE opId = :opId")
suspend fun deleteOutbox(opId: String)
// Deletions from server pull
@Query("DELETE FROM clips WHERE clipId IN (:ids) AND syncFlag NOT IN ('PENDING','CONFLICTED')")
suspend fun deleteClipsIfSafe(ids: List<String>)
// Helpers
@Query("SELECT MAX(cachedAt) FROM clips")
suspend fun lastCacheTime(): Long?
@Query("UPDATE clips SET lastOpenedAt = :now WHERE clipId = :id")
suspend fun touchClip(id: String, now: Long)
// Cleanup
@Query("""
DELETE FROM clips
WHERE syncFlag NOT IN ('PENDING','CONFLICTED')
AND lastOpenedAt < :cutoff
""")
suspend fun cleanupOldClips(cutoff: Long)
}
Room database: VaultDb
import androidx.room.Database
import androidx.room.RoomDatabase
@Database(
entities = [ClipEntity::class, OutboxOp::class],
version = 1,
exportSchema = true
)
abstract class VaultDb : RoomDatabase() {
abstract fun clipDao(): ClipDao
}
Network layer: Retrofit API (example)
Ideally your backend supports delta sync (token / cursor / ETags). We’ll model a token-based pull.
data class ClipDto(
val clipId: String,
val title: String,
val body: String,
val serverUpdatedAt: Long
)
data class SyncPullResponse(
val clips: List<ClipDto>,
val deletedIds: List<String>,
val newSyncToken: String
)
interface CourierApi {
// Push
suspend fun upsertClip(dto: ClipDto): ClipDto
suspend fun deleteClip(clipId: String)
// Pull
suspend fun pullChanges(syncToken: String?): SyncPullResponse
}
Repository: SWR reads + Outbox writes
The repository is your policy brain:
- UI always reads from Room
- Refresh happens in the background based on cache age
- Writes update Room immediately and enqueue outbox ops
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import java.util.UUID
class VaultRepository(
private val dao: ClipDao,
private val syncScheduler: SyncScheduler,
private val clock: () -> Long,
private val ioScope: CoroutineScope = CoroutineScope(Dispatchers.IO)
) {
fun observeClips(): Flow<List<ClipEntity>> {
ioScope.launch { maybeRefresh() }
return dao.observeClips()
}
fun observeClip(id: String): Flow<ClipEntity?> {
ioScope.launch { dao.touchClip(id, clock()) }
return dao.observeClip(id)
}
suspend fun saveClip(title: String, body: String, existingId: String? = null) {
val id = existingId ?: UUID.randomUUID().toString()
val now = clock()
val entity = ClipEntity(
clipId = id,
title = title,
body = body,
localUpdatedAt = now,
serverUpdatedAt = null,
cachedAt = now,
lastOpenedAt = now,
syncFlag = SyncFlag.PENDING
)
dao.upsertClip(entity)
val op = OutboxOp(
opId = UUID.randomUUID().toString(),
clipId = id,
kind = OpKind.UPSERT,
payloadJson = """{"clipId":"$id","title":${title.json()},"body":${body.json()},"serverUpdatedAt":0}""",
sequence = now,
attempts = 0,
lastAttemptAt = null
)
dao.enqueueOutbox(op)
syncScheduler.kick()
}
suspend fun removeClip(id: String) {
val now = clock()
// You might keep a "deleted" flag instead. This keeps the example short.
val op = OutboxOp(
opId = UUID.randomUUID().toString(),
clipId = id,
kind = OpKind.DELETE,
payloadJson = null,
sequence = now,
attempts = 0,
lastAttemptAt = null
)
dao.enqueueOutbox(op)
syncScheduler.kick()
}
private suspend fun maybeRefresh() {
val now = clock()
val last = dao.lastCacheTime() ?: 0L
val ageMs = now - last
val shouldRefresh = when {
last == 0L -> true
ageMs > 15 * 60_000L -> true // 15 minutes
else -> false
}
if (shouldRefresh) syncScheduler.refreshSoon()
}
}
private fun String.json(): String =
buildString {
append('"')
for (ch in this@json) {
when (ch) {
'\\' -> append("\\\\")
'"' -> append("\\\"")
'\n' -> append("\\n")
'\r' -> append("\\r")
'\t' -> append("\\t")
else -> append(ch)
}
}
append('"')
}
Scheduling sync with WorkManager
SyncScheduler
import android.content.Context
import androidx.work.*
class SyncScheduler(private val context: Context) {
fun kick() = enqueueUnique("vault_sync_now")
fun refreshSoon() = enqueueUnique("vault_sync_refresh")
private fun enqueueUnique(name: String) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = OneTimeWorkRequestBuilder<DispatchSyncWorker>()
.setConstraints(constraints)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
30_000L,
java.util.concurrent.TimeUnit.MILLISECONDS
)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
name,
ExistingWorkPolicy.KEEP,
request
)
}
}
Worker: push outbox → pull deltas → update Room
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import androidx.work.Result
import kotlinx.coroutines.delay
import java.util.UUID
class DispatchSyncWorker(
appContext: Context,
params: WorkerParameters,
private val db: VaultDb,
private val api: CourierApi,
private val syncTokenStore: SyncTokenStore,
private val clock: () -> Long
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
// Helps with flappy “connected for 1 second” networks
delay(1500)
val dao = db.clipDao()
return try {
// 1) PUSH pending ops in order
val outbox = dao.loadOutboxOrdered()
for (op in outbox) {
when (op.kind) {
OpKind.UPSERT -> {
val dto = op.payloadJson!!.toClipDto()
val saved = api.upsertClip(dto.copy(serverUpdatedAt = 0L))
dao.deleteOutbox(op.opId)
val now = clock()
dao.upsertClip(
ClipEntity(
clipId = saved.clipId,
title = saved.title,
body = saved.body,
localUpdatedAt = now,
serverUpdatedAt = saved.serverUpdatedAt,
cachedAt = now,
lastOpenedAt = now,
syncFlag = SyncFlag.SYNCED
)
)
}
OpKind.DELETE -> {
api.deleteClip(op.clipId)
dao.deleteOutbox(op.opId)
// You may also delete locally or keep tombstones.
}
}
}
// 2) PULL deltas
val token = syncTokenStore.readToken()
val pull = api.pullChanges(token)
// Apply deletions safely (don’t delete local pending/conflicted rows)
if (pull.deletedIds.isNotEmpty()) {
dao.deleteClipsIfSafe(pull.deletedIds)
}
// Apply upserts
val now = clock()
val entities = pull.clips.map { dto ->
ClipEntity(
clipId = dto.clipId,
title = dto.title,
body = dto.body,
localUpdatedAt = now,
serverUpdatedAt = dto.serverUpdatedAt,
cachedAt = now,
lastOpenedAt = now,
syncFlag = SyncFlag.SYNCED
)
}
if (entities.isNotEmpty()) dao.upsertClips(entities)
syncTokenStore.writeToken(pull.newSyncToken)
Result.success()
} catch (e: Exception) {
// UI stays on cached data; sync retries with WorkManager backoff
Result.retry()
}
}
}
// Placeholder parsing helpers (use Moshi/kotlinx.serialization in production)
private fun String.toClipDto(): ClipDto {
val id = Regex(""""clipId"\s*:\s*"([^"]+)"""").find(this)?.groupValues?.get(1)
?: UUID.randomUUID().toString()
val title = Regex(""""title"\s*:\s*"([^"]*)"""").find(this)?.groupValues?.get(1) ?: ""
val body = Regex(""""body"\s*:\s*"([^"]*)"""").find(this)?.groupValues?.get(1) ?: ""
return ClipDto(id, title, body, 0L)
}
interface SyncTokenStore {
fun readToken(): String?
fun writeToken(token: String)
}
UI hookup (ViewModel)
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class VaultViewModel(private val repo: VaultRepository) : ViewModel() {
val clips = repo.observeClips()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun onSave(title: String, body: String) = viewModelScope.launch {
repo.saveClip(title, body)
}
fun onDelete(id: String) = viewModelScope.launch {
repo.removeClip(id)
}
}
Sync sequence (end-to-end)
Practical guardrails
- Don’t trigger sync only on connectivity changes; use backoff and avoid storms.
- Keep writes idempotent (client UUIDs + server-side de-dupe).
- Never delete PENDING or CONFLICTED data during cleanup.
- For deletions, consider tombstones if you need reliable cross-device consistency.
- Prefer delta sync (token/ETag) over full refresh.
Closing
Offline-first is a reliability promise:
“Your work and your screen won’t vanish just because the network changed its mind.”
With Room + Flow + WorkManager, you already have the toolbox. The architecture is mostly a mindset shift: treat connectivity as variable, and design for progress anyway.



Top comments (0)