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

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.

// 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.

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

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.

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.

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

@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.

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 neededUI 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 DBMistake 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!