1. The Problem: Why Apps Break Without Internet

Picture your user on a subway. They open your note-taking app to jot down an idea. The app shows a spinner, waits 10 seconds, then says "No internet connection." The note they were typing is gone. They switch to Apple Notes, which works instantly, offline, every time.

// DON'T DO THIS - App is useless without internet
class NotesViewModel @Inject constructor(
    private val apiService: ApiService
) : ViewModel() {
    fun loadNotes() {
        viewModelScope.launch {
            try {
                val notes = apiService.getNotes()  // Fails without internet
                _uiState.value = UiState.Success(notes)
            } catch (e: IOException) {
                _uiState.value = UiState.Error("No internet connection")  // Useless
            }
        }
    }
    fun createNote(text: String) {
        viewModelScope.launch {
            try {
                apiService.createNote(NoteRequest(text))  // Fails without internet
            } catch (e: IOException) {
                _uiState.value = UiState.Error("Can't create note offline")  // User loses data
            }
        }
    }
}

The real-world impact:

  • 68% of the world's population experiences poor connectivity regularly
  • Users spend significant time in elevators, tunnels, airplanes, and rural areas
  • Even on LTE, packet loss and latency spikes cause timeouts
  • Users expect apps to work. Always.

2. Online-First vs Offline-First

None

Offline-first doesn't mean offline-only. It means the local database is the primary source of data, and the network is a sync mechanism that runs in the background.

3. Single Source of Truth — The Foundation

The core pattern behind every offline-first app: the local database is the single source of truth. The UI never reads from the network directly. It always reads from the database. The network writes to the database. The database emits updates to the UI.

None
// CORRECT APPROACH - Database is the single source of truth
class NoteRepository @Inject constructor(
    private val noteDao: NoteDao,
    private val apiService: ApiService
) {
    // UI observes this Flow - always has data, even offline
    fun observeNotes(): Flow<List<Note>> {
        return noteDao.observeAll()  // Room Flow auto-emits on data change
    }

    // Background sync - fetches from network and saves to DB
    suspend fun refreshNotes() {
        try {
            val remoteNotes = apiService.getNotes()
            noteDao.upsertAll(remoteNotes.map { it.toEntity() })
            // No return value needed - the Flow above auto-emits the updated data
        } catch (e: IOException) {
            // Silently fail - cached data is still available
        }
    }
}
// Room DAO with Flow observation
@Dao
interface NoteDao {
    @Query("SELECT * FROM notes ORDER BY updated_at DESC")
    fun observeAll(): Flow<List<NoteEntity>>
    @Upsert
    suspend fun upsertAll(notes: List<NoteEntity>)
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(note: NoteEntity)
    @Query("DELETE FROM notes WHERE id = :id")
    suspend fun deleteById(id: String)
}
// ViewModel - reads from DB, triggers background refresh
@HiltViewModel
class NotesViewModel @Inject constructor(
    private val repository: NoteRepository
) : ViewModel() {
    val notes: StateFlow<List<Note>> = repository.observeNotes()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
    init {
        refreshFromNetwork()
    }
    fun refreshFromNetwork() {
        viewModelScope.launch {
            repository.refreshNotes()
            // No need to update state - Room Flow auto-emits
        }
    }
}

Why this works:

  • The UI always has data to show (cached in Room)
  • Network success silently updates the DB, which auto-updates the UI via Flow
  • Network failure doesn't affect the user at all — they see cached data
  • Adding a new note locally makes it appear instantly, before sync

4. Caching Strategies

Not every screen needs the same caching behavior. Choose based on data freshness requirements.

None

Cache-First (Best for most screens)

Show cached data immediately, then refresh in background.

class ArticleRepository @Inject constructor(
    private val articleDao: ArticleDao,
    private val apiService: ApiService
) {
    fun getArticles(): Flow<Resource<List<Article>>> = flow {
        // 1. Emit cached data immediately
        val cached = articleDao.getAll()
        if (cached.isNotEmpty()) {
            emit(Resource.Success(cached.map { it.toDomain() }))
        } else {
            emit(Resource.Loading)
        }
        // 2. Fetch fresh data in background
        try {
            val remote = apiService.getArticles()
            articleDao.upsertAll(remote.map { it.toEntity() })
            // Room Flow will emit the updated list automatically
        } catch (e: Exception) {
            if (cached.isEmpty()) {
                emit(Resource.Error("No data available", e))
            }
            // If cache exists, silently fail
        }
    }
}

Network-First (For critical data)

Try network first, fall back to cache only on failure.

class PaymentRepository @Inject constructor(
    private val paymentDao: PaymentDao,
    private val apiService: ApiService
) {
    suspend fun getPaymentHistory(): Resource<List<Payment>> {
        return try {
            val remote = apiService.getPayments()
            paymentDao.upsertAll(remote.map { it.toEntity() })
            Resource.Success(remote.map { it.toDomain() })
        } catch (e: Exception) {
            val cached = paymentDao.getAll()
            if (cached.isNotEmpty()) {
                Resource.Success(cached.map { it.toDomain() }, isStale = true)
            } else {
                Resource.Error("Cannot load payments", e)
            }
        }
    }
}

Stale-While-Revalidate (For feeds and timelines)

Return cache instantly, then update in background and emit again.

class FeedRepository @Inject constructor(
    private val feedDao: FeedDao,
    private val apiService: ApiService
) {
    fun getFeed(): Flow<List<FeedItem>> = channelFlow {
        // Emit cached immediately
        val cached = feedDao.getAll()
        send(cached.map { it.toDomain() })
        // Revalidate in background
        try {
            val fresh = apiService.getFeed()
            feedDao.upsertAll(fresh.map { it.toEntity() })
            send(fresh.map { it.toDomain() })
        } catch (e: Exception) {
            // Cache already sent, silently fail
        }
    }
}

5. Building an Offline-First Repository

Here's the complete pattern for a feature that supports reads, writes, and sync:

sealed interface Resource<out T> {
    data class Success<T>(val data: T, val isStale: Boolean = false) : Resource<T>
    data class Error(val message: String, val exception: Throwable? = null) : Resource<Nothing>
    data object Loading : Resource<Nothing>
}

class TodoRepository @Inject constructor(
    private val todoDao: TodoDao,
    private val apiService: ApiService,
    private val networkMonitor: NetworkMonitor
) {
    // READ: Observe todos from local DB (single source of truth)
    fun observeTodos(): Flow<List<Todo>> {
        return todoDao.observeAll().map { entities ->
            entities.map { it.toDomain() }
        }
    }
    // REFRESH: Pull latest from server into local DB
    suspend fun refresh(): Result<Unit> {
        return try {
            val remoteTodos = apiService.getTodos()
            todoDao.upsertAll(remoteTodos.map { it.toEntity() })
            Result.success(Unit)
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
    // WRITE: Save locally first, then sync
    suspend fun createTodo(title: String, description: String): Result<Todo> {
        val localId = UUID.randomUUID().toString()
        val todo = TodoEntity(
            localId = localId,
            serverId = null,
            title = title,
            description = description,
            isCompleted = false,
            syncStatus = SyncStatus.PENDING_CREATE,
            updatedAt = System.currentTimeMillis()
        )
        todoDao.insert(todo)
        // Try immediate sync if online
        if (networkMonitor.isOnline()) {
            syncCreate(todo)
        }
        // If offline, WorkManager will pick it up later
        return Result.success(todo.toDomain())
    }
    // UPDATE: Update locally first, then sync
    suspend fun updateTodo(todo: Todo): Result<Unit> {
        todoDao.update(
            todo.toEntity().copy(
                syncStatus = SyncStatus.PENDING_UPDATE,
                updatedAt = System.currentTimeMillis()
            )
        )
        if (networkMonitor.isOnline()) {
            syncUpdate(todo.toEntity())
        }
        return Result.success(Unit)
    }
    // DELETE: Mark as deleted locally, sync later
    suspend fun deleteTodo(id: String): Result<Unit> {
        todoDao.markDeleted(id, SyncStatus.PENDING_DELETE)
        if (networkMonitor.isOnline()) {
            syncDelete(id)
        }
        return Result.success(Unit)
    }
    // SYNC: Push all pending changes to server
    suspend fun syncPendingChanges(): Result<Unit> {
        return try {
            val pendingCreates = todoDao.getBySyncStatus(SyncStatus.PENDING_CREATE)
            val pendingUpdates = todoDao.getBySyncStatus(SyncStatus.PENDING_UPDATE)
            val pendingDeletes = todoDao.getBySyncStatus(SyncStatus.PENDING_DELETE)
            pendingCreates.forEach { syncCreate(it) }
            pendingUpdates.forEach { syncUpdate(it) }
            pendingDeletes.forEach { syncDelete(it.localId) }
            Result.success(Unit)
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
    private suspend fun syncCreate(entity: TodoEntity) {
        try {
            val response = apiService.createTodo(entity.toRequest())
            todoDao.updateServerId(entity.localId, response.id, SyncStatus.SYNCED)
        } catch (e: Exception) {
            // Leave as PENDING_CREATE for next sync attempt
        }
    }
    private suspend fun syncUpdate(entity: TodoEntity) {
        try {
            val serverId = entity.serverId ?: return
            apiService.updateTodo(serverId, entity.toRequest())
            todoDao.updateSyncStatus(entity.localId, SyncStatus.SYNCED)
        } catch (e: Exception) {
            // Leave as PENDING_UPDATE for next sync attempt
        }
    }
    private suspend fun syncDelete(localId: String) {
        try {
            val entity = todoDao.getById(localId) ?: return
            val serverId = entity.serverId ?: run {
                todoDao.deleteByLocalId(localId)
                return
            }
            apiService.deleteTodo(serverId)
            todoDao.deleteByLocalId(localId)
        } catch (e: Exception) {
            // Leave as PENDING_DELETE for next sync attempt
        }
    }
}

The Sync Status Enum

enum class SyncStatus {
    SYNCED,           // Matches server
    PENDING_CREATE,   // Created locally, not yet on server
    PENDING_UPDATE,   // Modified locally, changes not pushed
    PENDING_DELETE    // Marked for deletion, not yet deleted on server
}

The Entity with Sync Metadata

@Entity(tableName = "todos")
data class TodoEntity(
    @PrimaryKey val localId: String,
    val serverId: String?,
    val title: String,
    val description: String,
    val isCompleted: Boolean,
    @ColumnInfo(name = "sync_status") val syncStatus: SyncStatus,
    @ColumnInfo(name = "updated_at") val updatedAt: Long
)

6. Network Connectivity Monitoring

You need to know when the device goes online so you can trigger sync.

interface NetworkMonitor {
    val isOnline: StateFlow<Boolean>
    fun isOnline(): Boolean
}

class NetworkMonitorImpl @Inject constructor(
    @ApplicationContext private val context: Context
) : NetworkMonitor {
    private val connectivityManager =
        context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
    override val isOnline: StateFlow<Boolean> = callbackFlow {
        val callback = object : ConnectivityManager.NetworkCallback() {
            override fun onAvailable(network: Network) {
                trySend(true)
            }
            override fun onLost(network: Network) {
                trySend(false)
            }
            override fun onCapabilitiesChanged(
                network: Network,
                capabilities: NetworkCapabilities
            ) {
                val hasInternet = capabilities.hasCapability(
                    NetworkCapabilities.NET_CAPABILITY_INTERNET
                ) && capabilities.hasCapability(
                    NetworkCapabilities.NET_CAPABILITY_VALIDATED
                )
                trySend(hasInternet)
            }
        }
        val request = NetworkRequest.Builder()
            .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
            .build()
        connectivityManager.registerNetworkCallback(request, callback)
        // Emit initial state
        trySend(isCurrentlyOnline())
        awaitClose {
            connectivityManager.unregisterNetworkCallback(callback)
        }
    }.stateIn(
        scope = CoroutineScope(Dispatchers.Default),
        started = SharingStarted.WhileSubscribed(5000),
        initialValue = isCurrentlyOnline()
    )
    override fun isOnline(): Boolean = isOnline.value
    private fun isCurrentlyOnline(): Boolean {
        val network = connectivityManager.activeNetwork ?: return false
        val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
        return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
            capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
    }
}

Triggering Sync When Connectivity Returns

@HiltViewModel
class TodosViewModel @Inject constructor(
    private val repository: TodoRepository,
    private val networkMonitor: NetworkMonitor
) : ViewModel() {
    init {
        observeConnectivity()
    }
    private fun observeConnectivity() {
        viewModelScope.launch {
            networkMonitor.isOnline
                .distinctUntilChanged()
                .filter { it }  // Only when coming online
                .collect {
                    repository.syncPendingChanges()
                    repository.refresh()
                }
        }
    }
}

7. Sync Queue — Write Operations While Offline

None

For apps with frequent offline writes (messaging, note-taking), a dedicated sync queue gives you retry logic, ordering, and idempotency.

@Entity(tableName = "sync_queue")
data class SyncOperation(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val entityType: String,        // "todo", "note", "comment"
    val entityId: String,          // local ID
    val operationType: String,     // "CREATE", "UPDATE", "DELETE"
    val payload: String,           // JSON body
    val retryCount: Int = 0,
    val maxRetries: Int = 5,
    val createdAt: Long = System.currentTimeMillis(),
    val status: String = "PENDING" // PENDING, IN_PROGRESS, FAILED, COMPLETED
)

@Dao
interface SyncQueueDao {
    @Query("SELECT * FROM sync_queue WHERE status = 'PENDING' ORDER BY createdAt ASC")
    suspend fun getPending(): List<SyncOperation>
    @Insert
    suspend fun enqueue(operation: SyncOperation)
    @Update
    suspend fun update(operation: SyncOperation)
    @Query("DELETE FROM sync_queue WHERE id = :id")
    suspend fun remove(id: Long)
    @Query("SELECT COUNT(*) FROM sync_queue WHERE status = 'PENDING'")
    fun observePendingCount(): Flow<Int>
}
class SyncQueueProcessor @Inject constructor(
    private val syncQueueDao: SyncQueueDao,
    private val apiService: ApiService,
    private val gson: Gson
) {
    suspend fun processQueue(): Result<Unit> {
        val pending = syncQueueDao.getPending()
        for (operation in pending) {
            syncQueueDao.update(operation.copy(status = "IN_PROGRESS"))
            try {
                executeOperation(operation)
                syncQueueDao.remove(operation.id)
            } catch (e: HttpException) {
                if (e.code() in 400..499 && e.code() != 429) {
                    // Client error (except rate limit) - don't retry
                    syncQueueDao.update(operation.copy(status = "FAILED"))
                } else {
                    handleRetry(operation)
                }
            } catch (e: IOException) {
                handleRetry(operation)
                break  // No network - stop processing queue
            }
        }
        return Result.success(Unit)
    }
    private suspend fun executeOperation(operation: SyncOperation) {
        when (operation.operationType) {
            "CREATE" -> {
                when (operation.entityType) {
                    "todo" -> {
                        val request = gson.fromJson(operation.payload, TodoRequest::class.java)
                        apiService.createTodo(request)
                    }
                }
            }
            "UPDATE" -> {
                when (operation.entityType) {
                    "todo" -> {
                        val request = gson.fromJson(operation.payload, TodoRequest::class.java)
                        apiService.updateTodo(operation.entityId, request)
                    }
                }
            }
            "DELETE" -> {
                when (operation.entityType) {
                    "todo" -> apiService.deleteTodo(operation.entityId)
                }
            }
        }
    }
    private suspend fun handleRetry(operation: SyncOperation) {
        if (operation.retryCount >= operation.maxRetries) {
            syncQueueDao.update(operation.copy(status = "FAILED"))
        } else {
            syncQueueDao.update(
                operation.copy(
                    status = "PENDING",
                    retryCount = operation.retryCount + 1
                )
            )
        }
    }
}

8. WorkManager for Background Sync

WorkManager guarantees your sync runs even if the app is killed or the device restarts.

@HiltWorker
class SyncWorker @AssistedInject constructor(
    @Assisted context: Context,
    @Assisted workerParams: WorkerParameters,
    private val syncProcessor: SyncQueueProcessor,
    private val repository: TodoRepository
) : CoroutineWorker(context, workerParams) {
    override suspend fun doWork(): Result {
        return try {
            // Push local changes
            syncProcessor.processQueue()
            // Pull remote changes
            repository.refresh()
            Result.success()
        } catch (e: Exception) {
            if (runAttemptCount < 3) {
                Result.retry()
            } else {
                Result.failure()
            }
        }
    }
    companion object {
        const val WORK_NAME = "sync_work"
    }
}

Scheduling Sync

class SyncScheduler @Inject constructor(
    private val workManager: WorkManager
) {
    // Periodic sync every 15 minutes when connected
    fun schedulePeriodicSync() {
        val constraints = Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .build()
    val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(
            15, TimeUnit.MINUTES
        )
            .setConstraints(constraints)
            .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
            .build()
        workManager.enqueueUniquePeriodicWork(
            SyncWorker.WORK_NAME,
            ExistingPeriodicWorkPolicy.KEEP,
            syncRequest
        )
    }
    // Immediate one-time sync (when user makes a change)
    fun triggerImmediateSync() {
        val constraints = Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .build()
        val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
            .setConstraints(constraints)
            .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
            .build()
        workManager.enqueueUniqueWork(
            "immediate_sync",
            ExistingWorkPolicy.REPLACE,
            syncRequest
        )
    }
}

9. Conflict Resolution

When two devices modify the same data offline and then sync, you have a conflict.

None

Last-Write-Wins (Simplest)

class LastWriteWinsResolver {
    fun resolve(local: TodoEntity, remote: TodoDto): TodoEntity {
        return if (local.updatedAt > remote.updatedAt) {
            local  // Local version is newer
        } else {
            remote.toEntity()  // Remote version is newer
        }
    }
}

Field-Level Merge (Smarter)

class FieldLevelMerger {

    fun merge(
        base: TodoEntity,      // Last known synced version
        local: TodoEntity,     // Local modifications
        remote: TodoDto        // Server modifications
    ): TodoEntity {
        return TodoEntity(
            localId = local.localId,
            serverId = remote.id,
            title = pickNewerField(base.title, local.title, remote.title),
            description = pickNewerField(base.description, local.description, remote.description),
            isCompleted = pickNewerField(base.isCompleted, local.isCompleted, remote.isCompleted),
            syncStatus = SyncStatus.SYNCED,
            updatedAt = maxOf(local.updatedAt, remote.updatedAt)
        )
    }
    private fun <T> pickNewerField(base: T, local: T, remote: T): T {
        return when {
            local != base && remote == base -> local   // Only local changed
            remote != base && local == base -> remote  // Only remote changed
            local == remote -> local                    // Both changed to same value
            else -> remote                              // Both changed differently - server wins
        }
    }

}

10. Pagination with Offline Support (Paging 3)

Paging 3 with RemoteMediator is the official pattern for paginated offline-first lists.

None

The RemoteMediator

@OptIn(ExperimentalPagingApi::class)
class ArticleRemoteMediator @Inject constructor(
    private val apiService: ApiService,
    private val database: AppDatabase
) : RemoteMediator<Int, ArticleEntity>() {
    private val articleDao = database.articleDao()
    private val remoteKeyDao = database.remoteKeyDao()
    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, ArticleEntity>
    ): MediatorResult {
        val page = when (loadType) {
            LoadType.REFRESH -> 1
            LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
            LoadType.APPEND -> {
                val remoteKey = remoteKeyDao.getRemoteKey("articles")
                    ?: return MediatorResult.Success(endOfPaginationReached = true)
                remoteKey.nextPage ?: return MediatorResult.Success(endOfPaginationReached = true)
            }
        }
        return try {
            val response = apiService.getArticles(page = page, pageSize = 20)
            database.withTransaction {
                if (loadType == LoadType.REFRESH) {
                    articleDao.clearAll()
                    remoteKeyDao.deleteByKey("articles")
                }
                val nextPage = if (response.articles.size < 20) null else page + 1
                remoteKeyDao.insertOrReplace(RemoteKey("articles", nextPage))
                articleDao.insertAll(response.articles.map { it.toEntity() })
            }
            MediatorResult.Success(endOfPaginationReached = response.articles.size < 20)
        } catch (e: IOException) {
            MediatorResult.Error(e)
        } catch (e: HttpException) {
            MediatorResult.Error(e)
        }
    }
}

The Pager Setup

class ArticleRepository @Inject constructor(
    private val database: AppDatabase,
    private val remoteMediator: ArticleRemoteMediator
) {
    @OptIn(ExperimentalPagingApi::class)
    fun getArticlesPaged(): Flow<PagingData<Article>> {
        return Pager(
            config = PagingConfig(
                pageSize = 20,
                prefetchDistance = 5,
                enablePlaceholders = false
            ),
            remoteMediator = remoteMediator,
            pagingSourceFactory = { database.articleDao().pagingSource() }
        ).flow.map { pagingData ->
            pagingData.map { it.toDomain() }
        }
    }
}

Room PagingSource

@Dao
interface ArticleDao {
    @Query("SELECT * FROM articles ORDER BY published_at DESC")
    fun pagingSource(): PagingSource<Int, ArticleEntity>
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(articles: List<ArticleEntity>)
    @Query("DELETE FROM articles")
    suspend fun clearAll()
}

ViewModel with Paging

@HiltViewModel
class ArticlesViewModel @Inject constructor(
    private val repository: ArticleRepository
) : ViewModel() {
    val articles: Flow<PagingData<Article>> = repository.getArticlesPaged()
        .cachedIn(viewModelScope)
}

This setup gives you: pagination from the API, offline access from Room, automatic background sync via RemoteMediator, and cached pages that survive process death.

11. Network Layer Design

None
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

    @Provides
    @Singleton
    fun provideOkHttpClient(
        authInterceptor: AuthInterceptor,
        @ApplicationContext context: Context
    ): OkHttpClient {
        val cacheSize = 50L * 1024 * 1024  // 50 MB
        val cache = Cache(context.cacheDir.resolve("http_cache"), cacheSize)
        return OkHttpClient.Builder()
            .cache(cache)
            .addInterceptor(authInterceptor)
            .addInterceptor(OfflineCacheInterceptor(context))
            .addNetworkInterceptor(OnlineCacheInterceptor())
            .connectTimeout(15, TimeUnit.SECONDS)
            .readTimeout(15, TimeUnit.SECONDS)
            .build()
    }
    @Provides
    @Singleton
    fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
        return Retrofit.Builder()
            .baseUrl("https://api.example.com/")
            .client(okHttpClient)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }
}

Auth Interceptor with Token Refresh

class AuthInterceptor @Inject constructor(
    private val tokenManager: TokenManager
) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val token = tokenManager.getAccessToken()
        val request = chain.request().newBuilder()
            .addHeader("Authorization", "Bearer $token")
            .build()
        val response = chain.proceed(request)
        if (response.code == 401) {
            response.close()
            val newToken = runBlocking { tokenManager.refreshToken() }
                ?: throw AuthenticationException("Token refresh failed")
            val retryRequest = chain.request().newBuilder()
                .addHeader("Authorization", "Bearer $newToken")
                .build()
            return chain.proceed(retryRequest)
        }
        return response
    }
}

12. OkHttp Cache for HTTP-Level Caching

OkHttp's built-in HTTP cache handles Cache-Control headers automatically. For APIs you control, this gives you free caching.

Force Cache When Offline

class OfflineCacheInterceptor @Inject constructor(
    @ApplicationContext private val context: Context
) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        var request = chain.request()
        if (!isNetworkAvailable()) {
            request = request.newBuilder()
                .cacheControl(
                    CacheControl.Builder()
                        .maxStale(7, TimeUnit.DAYS)  // Use cache up to 7 days old
                        .build()
                )
                .build()
        }
        return chain.proceed(request)
    }
    private fun isNetworkAvailable(): Boolean {
        val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
        val network = cm.activeNetwork ?: return false
        val capabilities = cm.getNetworkCapabilities(network) ?: return false
        return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
    }
}

Set Cache Headers When Online

class OnlineCacheInterceptor : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val response = chain.proceed(chain.request())
        val cacheControl = CacheControl.Builder()
            .maxAge(5, TimeUnit.MINUTES)  // Cache valid for 5 minutes
            .build()
        return response.newBuilder()
            .removeHeader("Pragma")
            .removeHeader("Cache-Control")
            .header("Cache-Control", cacheControl.toString())
            .build()
    }
}

Use both: OkHttp cache for API response caching. Room for structured data that needs querying, offline writes, and Flow observation.

13. DataStore for Lightweight Preferences

For small key-value data (user settings, feature flags, tokens), DataStore replaces SharedPreferences.

// Preferences DataStore
@Module
@InstallIn(SingletonComponent::class)
object DataStoreModule {
    @Provides
    @Singleton
    fun providePreferencesDataStore(
        @ApplicationContext context: Context
    ): DataStore<Preferences> {
        return PreferenceDataStoreFactory.create {
            context.preferencesDataStoreFile("user_preferences")
        }
    }
}
class UserPreferences @Inject constructor(
    private val dataStore: DataStore<Preferences>
) {
    companion object {
        val DARK_MODE = booleanPreferencesKey("dark_mode")
        val LANGUAGE = stringPreferencesKey("language")
        val LAST_SYNC_TIME = longPreferencesKey("last_sync_time")
        val ONBOARDING_COMPLETE = booleanPreferencesKey("onboarding_complete")
    }
    val darkMode: Flow<Boolean> = dataStore.data.map { it[DARK_MODE] ?: false }
    val language: Flow<String> = dataStore.data.map { it[LANGUAGE] ?: "en" }
    val lastSyncTime: Flow<Long> = dataStore.data.map { it[LAST_SYNC_TIME] ?: 0L }
    suspend fun setDarkMode(enabled: Boolean) {
        dataStore.edit { it[DARK_MODE] = enabled }
    }
    suspend fun setLanguage(lang: String) {
        dataStore.edit { it[LANGUAGE] = lang }
    }
    suspend fun updateLastSyncTime() {
        dataStore.edit { it[LAST_SYNC_TIME] = System.currentTimeMillis() }
    }
}

Proto DataStore (Type-safe)

For more complex structured preferences:

// user_settings.proto
// syntax = "proto3";
// message UserSettings {
//   bool dark_mode = 1;
//   string language = 2;
//   int32 font_size = 3;
//   bool notifications_enabled = 4;
// }

class UserSettingsSerializer @Inject constructor() : Serializer<UserSettings> {
    override val defaultValue: UserSettings = UserSettings.getDefaultInstance()
    override suspend fun readFrom(input: InputStream): UserSettings {
        return UserSettings.parseFrom(input)
    }
    override suspend fun writeTo(t: UserSettings, output: OutputStream) {
        t.writeTo(output)
    }
}

14. Image Caching and Offline Media

Images are the heaviest offline content. Coil and Glide both handle disk caching automatically.

// Coil setup with offline-friendly config
@Module
@InstallIn(SingletonComponent::class)
object ImageModule {
@Provides
    @Singleton
    fun provideImageLoader(
        @ApplicationContext context: Context,
        okHttpClient: OkHttpClient
    ): ImageLoader {
        return ImageLoader.Builder(context)
            .okHttpClient(okHttpClient)  // Reuses app's OkHttp cache
            .diskCachePolicy(CachePolicy.ENABLED)
            .memoryCachePolicy(CachePolicy.ENABLED)
            .networkCachePolicy(CachePolicy.ENABLED)
            .diskCache {
                DiskCache.Builder()
                    .directory(context.cacheDir.resolve("image_cache"))
                    .maxSizePercent(0.05)  // 5% of device storage
                    .build()
            }
            .crossfade(true)
            .build()
    }
}

Pre-caching Important Images

class ImagePreloader @Inject constructor(
    private val imageLoader: ImageLoader,
    @ApplicationContext private val context: Context
) {
    suspend fun preloadUserAvatars(users: List<User>) {
        users.forEach { user ->
            val request = ImageRequest.Builder(context)
                .data(user.avatarUrl)
                .size(Size.ORIGINAL)
                .build()
            imageLoader.enqueue(request)
        }
    }
}

15. Push Notifications for Sync Triggers

Instead of polling the server every N minutes, let the server tell your app when new data is available.

class SyncFirebaseMessagingService : FirebaseMessagingService() {
    @Inject lateinit var syncScheduler: SyncScheduler
    override fun onMessageReceived(remoteMessage: RemoteMessage) {
        val syncType = remoteMessage.data["sync_type"]
        when (syncType) {
            "full_sync" -> syncScheduler.triggerImmediateSync()
            "todos_updated" -> syncScheduler.syncEntity("todos")
            "user_profile_changed" -> syncScheduler.syncEntity("profile")
        }
    }
}

Recommendation: Use push notifications to trigger sync + periodic WorkManager as a fallback.

16. System Design: Building a Full Offline-First Feature

Let's design a complete offline-first todo app from scratch.

None

Requirements

  • User can view, create, edit, and delete todos
  • Works fully offline — all operations instant
  • Syncs automatically when connectivity is available
  • Supports pagination for large lists
  • Shows sync status indicators per item
  • Handles conflicts when same item edited on multiple devices

Data Flow

User creates todo
  → Saved to Room with status=PENDING_CREATE
  → UI shows todo instantly (with sync pending indicator)
  → WorkManager schedules sync
  → When online: POST /todos → response saved to Room with serverId
  → UI updates: sync indicator disappears
User edits todo while offline
  → Room updated with status=PENDING_UPDATE
  → UI shows changes instantly
  → Changes queued in sync_queue table
  → When connectivity returns:
    → WorkManager fires SyncWorker
    → SyncQueueProcessor processes pending operations in order
    → On success: status=SYNCED
    → On conflict: field-level merge, notify user if needed

UI Sync Indicator

@Composable
fun TodoItem(
    todo: Todo,
    onEvent: (TodoEvent) -> Unit
) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp),
        verticalAlignment = Alignment.CenterVertically
    ) {
        Checkbox(
            checked = todo.isCompleted,
            onCheckedChange = { onEvent(TodoEvent.ToggleComplete(todo.id)) }
        )

    Column(modifier = Modifier.weight(1f)) {
            Text(todo.title, style = MaterialTheme.typography.bodyLarge)
            Text(todo.description, style = MaterialTheme.typography.bodySmall)
        }
        // Sync status indicator
        when (todo.syncStatus) {
            SyncStatus.PENDING_CREATE,
            SyncStatus.PENDING_UPDATE -> {
                Icon(
                    imageVector = Icons.Default.CloudUpload,
                    contentDescription = "Pending sync",
                    tint = MaterialTheme.colorScheme.tertiary
                )
            }
            SyncStatus.PENDING_DELETE -> {
                Icon(
                    imageVector = Icons.Default.DeleteForever,
                    contentDescription = "Pending delete",
                    tint = MaterialTheme.colorScheme.error
                )
            }
            SyncStatus.SYNCED -> {
                // No indicator needed - synced is the default state
            }
        }
    }
}

Offline Banner

@Composable
fun OfflineBanner(networkMonitor: NetworkMonitor) {
    val isOnline by networkMonitor.isOnline.collectAsStateWithLifecycle()
    AnimatedVisibility(
        visible = !isOnline,
        enter = slideInVertically() + fadeIn(),
        exit = slideOutVertically() + fadeOut()
    ) {
        Surface(
            color = MaterialTheme.colorScheme.errorContainer,
            modifier = Modifier.fillMaxWidth()
        ) {
            Text(
                text = "You're offline. Changes will sync when connected.",
                modifier = Modifier.padding(12.dp),
                style = MaterialTheme.typography.bodySmall,
                textAlign = TextAlign.Center
            )
        }
    }
}

17. Scaling for Millions of Users

Efficient Sync with Delta Updates

// Instead of fetching ALL data every time, fetch only changes since last sync
class DeltaSyncRepository @Inject constructor(
    private val apiService: ApiService,
    private val todoDao: TodoDao,
    private val preferences: UserPreferences
) {
    suspend fun incrementalSync() {
        val lastSync = preferences.lastSyncTime.first()
        val changes = apiService.getChanges(
            since = lastSync,
            entityTypes = listOf("todos")
        )
        todoDao.applyChanges(
            upserts = changes.upserted.map { it.toEntity() },
            deletes = changes.deletedIds
        )
        preferences.updateLastSyncTime()
    }
}
// Server endpoint: GET /changes?since=1708123456&entities=todos
// Returns only records modified after the timestamp
data class ChangesResponse(
    val upserted: List<TodoDto>,
    val deletedIds: List<String>,
    val serverTimestamp: Long
)

Batch Operations for Efficiency

// DON'T DO THIS - N network calls for N items
suspend fun syncAll(items: List<TodoEntity>) {
    items.forEach { item ->
        apiService.createTodo(item.toRequest())  // N requests!
    }
}

// CORRECT APPROACH - Single batch request
suspend fun syncAll(items: List<TodoEntity>) {
    val batch = items.map { it.toRequest() }
    apiService.batchCreateTodos(BatchRequest(batch))  // 1 request
}

Database Optimization for Large Datasets

@Dao
interface TodoDao {
// Index frequently queried columns
    @Query("SELECT * FROM todos WHERE sync_status != 'PENDING_DELETE' ORDER BY updated_at DESC LIMIT :limit OFFSET :offset")
    suspend fun getPage(limit: Int, offset: Int): List<TodoEntity>
    // Batch operations in a transaction
    @Transaction
    suspend fun applyChanges(upserts: List<TodoEntity>, deletes: List<String>) {
        upsertAll(upserts)
        deleteByIds(deletes)
    }
    @Upsert
    suspend fun upsertAll(entities: List<TodoEntity>)
    @Query("DELETE FROM todos WHERE serverId IN (:ids)")
    suspend fun deleteByIds(ids: List<String>)
}

18. Testing Offline Scenarios

Unit Testing the Repository

class TodoRepositoryTest {
    private val fakeDao = FakeTodoDao()
    private val fakeApi = FakeApiService()
    private val fakeNetworkMonitor = FakeNetworkMonitor()
    private lateinit var repository: TodoRepository
    @Before
    fun setup() {
        repository = TodoRepository(fakeDao, fakeApi, fakeNetworkMonitor)
    }
    @Test
    fun `createTodo saves locally when offline`() = runTest {
        fakeNetworkMonitor.setOnline(false)
        val result = repository.createTodo("Buy groceries", "Milk, eggs, bread")
        assertThat(result.isSuccess).isTrue()
        assertThat(fakeDao.getAll()).hasSize(1)
        assertThat(fakeDao.getAll().first().syncStatus).isEqualTo(SyncStatus.PENDING_CREATE)
    }
    @Test
    fun `createTodo saves and syncs when online`() = runTest {
        fakeNetworkMonitor.setOnline(true)
        fakeApi.createResponse = TodoDto(id = "server-1", title = "Buy groceries")
        val result = repository.createTodo("Buy groceries", "Milk, eggs")
        assertThat(result.isSuccess).isTrue()
        assertThat(fakeDao.getAll().first().serverId).isEqualTo("server-1")
        assertThat(fakeDao.getAll().first().syncStatus).isEqualTo(SyncStatus.SYNCED)
    }
    @Test
    fun `observeTodos returns cached data when API fails`() = runTest {
        fakeDao.insert(TodoEntity(localId = "1", title = "Cached todo", syncStatus = SyncStatus.SYNCED, ...))
        fakeApi.shouldFail = true
        val todos = repository.observeTodos().first()
        assertThat(todos).hasSize(1)
        assertThat(todos.first().title).isEqualTo("Cached todo")
    }
    @Test
    fun `syncPendingChanges processes all pending operations`() = runTest {
        fakeDao.insert(TodoEntity(syncStatus = SyncStatus.PENDING_CREATE, ...))
        fakeDao.insert(TodoEntity(syncStatus = SyncStatus.PENDING_UPDATE, ...))
        fakeNetworkMonitor.setOnline(true)
        repository.syncPendingChanges()
        val todos = fakeDao.getAll()
        assertThat(todos.all { it.syncStatus == SyncStatus.SYNCED }).isTrue()
    }
}

Testing the Sync Worker

@HiltAndroidTest
class SyncWorkerTest {
    @get:Rule
    val hiltRule = HiltAndroidRule(this)
    @Test
    fun `SyncWorker returns success when sync completes`() = runTest {
        val worker = TestListenableWorkerBuilder<SyncWorker>(
            ApplicationProvider.getApplicationContext()
        ).build()
        val result = worker.doWork()
        assertThat(result).isEqualTo(ListenableWorker.Result.success())
    }
    @Test
    fun `SyncWorker retries on network failure`() = runTest {
        fakeApi.shouldFail = true
        val worker = TestListenableWorkerBuilder<SyncWorker>(
            ApplicationProvider.getApplicationContext()
        ).setRunAttemptCount(0).build()
        val result = worker.doWork()
        assertThat(result).isEqualTo(ListenableWorker.Result.retry())
    }
}

Fake Network Monitor for Tests

class FakeNetworkMonitor : NetworkMonitor {
    private val _isOnline = MutableStateFlow(true)
    override val isOnline: StateFlow<Boolean> = _isOnline.asStateFlow()
    override fun isOnline(): Boolean = _isOnline.value
    fun setOnline(online: Boolean) {
        _isOnline.value = online
    }
}

19. Common Mistakes and Pitfalls

Mistake 1: UI Reads Directly From Network

// DON'T DO THIS
val users = apiService.getUsers()  // Fails offline, no caching
_uiState.value = UiState.Success(users)

// CORRECT APPROACH
val users = userDao.observeAll()  // Always available
// Network refresh happens separately and writes to DB

Mistake 2: Not Handling Stale Data

// DON'T DO THIS - User has no idea data is 3 days old
Text(text = "Your balance: $${balance}")

// CORRECT APPROACH - Show freshness indicator
Column {
    Text(text = "Your balance: $${balance}")
    if (isStale) {
        Text(
            text = "Last updated: ${formatRelativeTime(lastSyncTime)}",
            style = MaterialTheme.typography.labelSmall,
            color = MaterialTheme.colorScheme.outline
        )
    }
}

Mistake 3: Ignoring Sync Order

// DON'T DO THIS - Parallel sync causes race conditions
pendingOperations.forEach { operation ->
    launch { executeOperation(operation) }  // Order not guaranteed!
}

// CORRECT APPROACH - Sequential sync preserves order
pendingOperations.forEach { operation ->
    executeOperation(operation)  // Create before update, update before delete
}

Mistake 4: Unbounded Local Database Growth

// DON'T DO THIS - Database grows forever
@Upsert
suspend fun upsertAll(entities: List<ArticleEntity>)

// CORRECT APPROACH - Clean up old data periodically
@Transaction
suspend fun refreshAndCleanup(fresh: List<ArticleEntity>, keepDays: Int = 30) {
    val cutoff = System.currentTimeMillis() - (keepDays * 24 * 60 * 60 * 1000L)
    deleteOlderThan(cutoff)
    upsertAll(fresh)
}
@Query("DELETE FROM articles WHERE created_at < :cutoffTimestamp AND sync_status = 'SYNCED'")
suspend fun deleteOlderThan(cutoffTimestamp: Long)

Mistake 5: Using System Clock for Timestamps

// DON'T DO THIS - Device clock can be wrong or manipulated
val updatedAt = System.currentTimeMillis()

// CORRECT APPROACH - Use server timestamp for sync, local clock for ordering only
data class TodoEntity(
    val localUpdatedAt: Long = System.currentTimeMillis(),  // Local ordering
    val serverUpdatedAt: Long? = null  // Source of truth for conflicts
)

Before you leave —

Follow me and clap to show your support. It keeps me motivated to write more and more.

Thanks for the read!