Implementing Offline Mode in Mobile Application
Offline mode is not just "show cache when there's no network." It's an architectural decision affecting all app layers: how to store data, show actuality, what user can do without internet, queue actions and sync after reconnection.
Mobile internet drops in metro, elevator, poor coverage. App that just spins and waits loses users.
Architectural Foundation: Local-First
Principle is simple: local database is UI source of truth. Network is synchronization, not requirement for displaying data.
UI → ViewModel → Repository
├── LocalDataSource (Room/SQLite) ← UI reads from here
└── RemoteDataSource (API) ← background sync
UI never makes direct network requests. Everything through Repository, which first returns local data, then updates in background.
class ArticleRepository(
private val localDao: ArticleDao,
private val api: ArticleApi,
private val syncManager: SyncManager
) {
// UI subscribed to this Flow — gets data immediately from DB
fun observeArticles(categoryId: String): Flow<List<Article>> =
localDao.observeByCategory(categoryId)
.map { entities -> entities.map { it.toDomain() } }
// Called on startup, pull-to-refresh, network recovery
suspend fun refresh(categoryId: String) {
try {
val remote = api.getArticles(categoryId)
localDao.upsertAll(remote.map { it.toEntity() })
} catch (e: NetworkException) {
// Don't propagate — UI just sees old data
syncManager.scheduleSyncWhenOnline(SyncTask.RefreshArticles(categoryId))
}
}
}
Detecting Network State
On Android — ConnectivityManager with NetworkCallback:
class NetworkMonitor(context: Context) {
private val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val isOnline: StateFlow<Boolean> = callbackFlow {
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { trySend(true) }
override fun onLost(network: Network) { trySend(false) }
}
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, callback)
awaitClose { connectivityManager.unregisterNetworkCallback(callback) }
}.stateIn(
scope = CoroutineScope(Dispatchers.IO),
started = SharingStarted.WhileSubscribed(5000),
initialValue = connectivityManager.isCurrentlyConnected()
)
}
NET_CAPABILITY_INTERNET doesn't mean real internet — captive portal (hotel WiFi without auth) passes this check. For reliability add NET_CAPABILITY_VALIDATED.
On iOS — NWPathMonitor from Network framework:
let monitor = NWPathMonitor()
monitor.pathUpdateHandler = { path in
let isConnected = path.status == .satisfied
DispatchQueue.main.async {
self.networkState = isConnected ? .online : .offline
}
}
monitor.start(queue: DispatchQueue.global(qos: .background))
Offline Actions: Operation Queue
User taps "Send" without internet. Can't just show error. Right way — queue the action:
@Entity(tableName = "pending_operations")
data class PendingOperation(
@PrimaryKey val id: String = UUID.randomUUID().toString(),
val type: String, // "CREATE_ORDER", "UPDATE_PROFILE", "DELETE_ITEM"
val payload: String, // JSON
val createdAt: Long = System.currentTimeMillis(),
val retryCount: Int = 0,
val status: String = "PENDING" // PENDING, PROCESSING, FAILED
)
On network recovery — WorkManager processes the queue:
class OfflineSyncWorker(
context: Context,
params: WorkerParameters,
private val operationDao: PendingOperationDao,
private val api: AppApi
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val pending = operationDao.getPendingOperations()
for (operation in pending) {
try {
operationDao.markProcessing(operation.id)
when (operation.type) {
"CREATE_ORDER" -> {
val order = Json.decodeFromString<CreateOrderRequest>(operation.payload)
api.createOrder(order)
}
"UPDATE_PROFILE" -> {
val update = Json.decodeFromString<UpdateProfileRequest>(operation.payload)
api.updateProfile(update)
}
}
operationDao.delete(operation.id)
} catch (e: Exception) {
operationDao.incrementRetry(operation.id)
if (operation.retryCount >= 3) {
operationDao.markFailed(operation.id)
notifyUser(operation) // show error to user
}
}
}
return Result.success()
}
}
// WorkManager registration with network condition
val syncRequest = OneTimeWorkRequestBuilder<OfflineSyncWorker>()
.setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED))
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 15, TimeUnit.SECONDS)
.build()
WorkManager on Android is the right tool for deferred operations. Survives app and device restart. Don't use coroutines directly for this — they live only while process is alive.
On iOS — Background Tasks framework (BGTaskScheduler):
BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.app.offline-sync",
using: nil) { task in
self.handleOfflineSync(task: task as! BGProcessingTask)
}
UX: What to Show User
Bland "No internet" toast is bad. User needs to understand:
- Data is fresh or stale (and how much)
- What they can do offline
- What queues and executes later
Show last sync timestamp in screen header. Send button offline changes text to "Send when connected" and changes style. Pending operations show in UI as "waiting for sync" until server confirms.
Common Problems
Optimistic update without rollback. Updated UI immediately (optimistically), operation in queue — user sees change. Server returned error — need rollback mechanism. Without it, UI shows non-existent state.
Concurrent writes. User made changes offline, same data changed on another device in parallel. Need conflict resolution strategy — separate task.
Large data volumes. Don't cache everything. Cache what user likely opens: current screen, data from last N days, favorites.
Offline mode implementation with operation queue, WorkManager and UX for both platforms: 3–5 weeks depending on domain logic complexity. Cost calculated individually.







