androidengineers.Book a session

Caching Strategies

Designing Offline-First Flows

article25 minHard

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:

StrategyDescriptionUse when
Last-write-wins (timestamp)Latest updatedAt winsSingle-user data
Server winsDiscard local changesContent created server-side
Client winsPush local changes regardlessUser-generated content
MergeField-level merge by operational transformCollaborative 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.

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Designing Offline-First Flows | Android System Design | Android Engineers