Offline-First System in Android
Imagine you’re reading a newspaper on a train that goes through several tunnels. In the old days, you’d have the entire physical newspaper with you — tunnels wouldn’t matter. But today, if you’re reading news on your phone and rely entirely on the internet, every tunnel means a frustrating interruption. This is exactly the problem offline-first systems solve: they bring back that newspaper-like reliability to our digital apps.
An offline-first system is an architectural approach where your app is designed to work without an internet connection as the primary mode of operation. Network connectivity becomes an enhancement, not a requirement.
What is Offline-First Architecture?
Offline-first means your app is designed to work primarily with local data, treating the network as an enhancement rather than a requirement. The app functions fully offline, and network connectivity is used to synchronize data when available.
Why Build Offline-First Apps?
User Experience Benefits:
Technical Benefits:
Business Benefits:
Core Principles
Before diving into implementation, understand these fundamental principles:
Architecture Overview
An offline-first Android system consists of several layers:
Deep Dive: Building Each Component
1. Local Database with Room
Room is Android’s recommended database library. It provides compile-time verification of SQL queries and abstracts SQLite.
Why Room?
Internal Working: Room consists of three main components:
Database: The holder of your database Entity: Represents a table DAO: Contains methods for database operations
Here’s a practical example:
// Entity - Represents a table row
@Entity(tableName = "articles")
data class Article(
@PrimaryKey val id: String,
val title: String,
val content: String,
val authorId: String,
@ColumnInfo(name = "created_at") val createdAt: Long,
@ColumnInfo(name = "updated_at") val updatedAt: Long,
@ColumnInfo(name = "is_synced") val isSynced: Boolean = false,
@ColumnInfo(name = "pending_operation") val pendingOperation: String? = null // CREATE, UPDATE, DELETE
)
// DAO - Database Access Object
@Dao
interface ArticleDao {
// Observe changes with Flow - emits whenever data changes
@Query("SELECT * FROM articles ORDER BY created_at DESC")
fun getAllArticlesFlow(): Flow<List<Article>>
// Get single item
@Query("SELECT * FROM articles WHERE id = :id")
suspend fun getArticleById(id: String): Article?
// Insert or replace
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertArticle(article: Article)
// Insert multiple
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertArticles(articles: List<Article>)
// Update
@Update
suspend fun updateArticle(article: Article)
// Delete
@Delete
suspend fun deleteArticle(article: Article)
// Get unsynced items for background sync
@Query("SELECT * FROM articles WHERE is_synced = 0")
suspend fun getUnsyncedArticles(): List<Article>
// Custom query for pending operations
@Query("SELECT * FROM articles WHERE pending_operation IS NOT NULL")
suspend fun getPendingOperations(): List<Article>
}
// Database class
@Database(
entities = [Article::class],
version = 1,
exportSchema = false
)
abstract class AppDatabase : RoomDatabase() {
abstract fun articleDao(): ArticleDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getDatabase(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"offline_first_database"
)
.fallbackToDestructiveMigration()
.build()
INSTANCE = instance
instance
}
}
}
}
How Room Works Internally:
When you call a DAO method, Room generates the implementation at compile time. For example, when you call getAllArticlesFlow(), Room:
The suspend keyword means these functions can be called from coroutines without blocking the main thread.
2. Network Layer with Retrofit
Retrofit handles API calls to your backend server.
Why Retrofit?
// API Response wrapper
sealed class ApiResponse<out T> {
data class Success<T>(val data: T) : ApiResponse<T>()
data class Error(val message: String, val code: Int? = null) : ApiResponse<Nothing>()
object NetworkError : ApiResponse<Nothing>()
}
// API Service Interface
interface ArticleApiService {
@GET("articles")
suspend fun getArticles(
@Query("since") lastSyncTimestamp: Long? = null
): List<Article>
@GET("articles/{id}")
suspend fun getArticleById(@Path("id") id: String): Article
@POST("articles")
suspend fun createArticle(@Body article: Article): Article
@PUT("articles/{id}")
suspend fun updateArticle(
@Path("id") id: String,
@Body article: Article
): Article
@DELETE("articles/{id}")
suspend fun deleteArticle(@Path("id") id: String): Response<Unit>
}
// Retrofit instance
object RetrofitClient {
private const val BASE_URL = "https://api.example.com/"
private val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
private val client = OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.addInterceptor { chain ->
// Add auth token
val request = chain.request().newBuilder()
.addHeader("Authorization", "Bearer ${getAuthToken()}")
.build()
chain.proceed(request)
}
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
val apiService: ArticleApiService by lazy {
Retrofit.Builder()
.baseUrl(BASE_URL)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(ArticleApiService::class.java)
}
private fun getAuthToken(): String {
// Retrieve token from secure storage
return "your_token_here"
}
}
How Retrofit Works Internally:
When you call getArticles(), Retrofit:
The suspend keyword integrates with Kotlin coroutines, so the network call doesn't block the UI thread.
3. Repository Pattern — The Brain of Offline-First
The Repository is where the magic happens. It decides whether to serve local or remote data and handles synchronization.
Why Repository Pattern?
class ArticleRepository(
private val articleDao: ArticleDao,
private val apiService: ArticleApiService,
private val connectivityManager: ConnectivityManager,
private val coroutineScope: CoroutineScope
) {
// Expose articles as Flow - UI observes this
val articles: Flow<List<Article>> = articleDao.getAllArticlesFlow()
// Check network connectivity
private fun isNetworkAvailable(): Boolean {
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
/**
* Get articles with offline-first strategy:
* 1. Immediately return cached data
* 2. Fetch from network in background
* 3. Update cache with fresh data
*/
suspend fun refreshArticles(): Result<Unit> {
// If offline, just return success - we already have local data
if (!isNetworkAvailable()) {
return Result.success(Unit)
}
return try {
// Get last sync timestamp
val lastSync = getLastSyncTimestamp()
// Fetch from API
val remoteArticles = apiService.getArticles(lastSync)
// Update local database
articleDao.insertArticles(remoteArticles)
// Update last sync timestamp
updateLastSyncTimestamp(System.currentTimeMillis())
Result.success(Unit)
} catch (e: Exception) {
Log.e("ArticleRepository", "Failed to refresh articles", e)
Result.failure(e)
}
}
/**
* Create article with optimistic update:
* 1. Save to local DB immediately
* 2. Update UI instantly
* 3. Sync to server in background
*/
suspend fun createArticle(title: String, content: String, authorId: String): Result<Article> {
// Create article with temporary ID
val localArticle = Article(
id = "temp_${System.currentTimeMillis()}",
title = title,
content = content,
authorId = authorId,
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis(),
isSynced = false,
pendingOperation = "CREATE"
)
// Save to local DB immediately (optimistic update)
articleDao.insertArticle(localArticle)
// Try to sync to server
if (isNetworkAvailable()) {
coroutineScope.launch {
syncArticleToServer(localArticle)
}
}
return Result.success(localArticle)
}
/**
* Update article with optimistic update
*/
suspend fun updateArticle(article: Article): Result<Article> {
// Update locally first
val updatedArticle = article.copy(
updatedAt = System.currentTimeMillis(),
isSynced = false,
pendingOperation = "UPDATE"
)
articleDao.updateArticle(updatedArticle)
// Sync to server
if (isNetworkAvailable()) {
coroutineScope.launch {
syncArticleToServer(updatedArticle)
}
}
return Result.success(updatedArticle)
}
/**
* Delete article with optimistic update
*/
suspend fun deleteArticle(article: Article): Result<Unit> {
// Mark for deletion locally
val markedForDeletion = article.copy(
isSynced = false,
pendingOperation = "DELETE"
)
articleDao.updateArticle(markedForDeletion)
// Sync to server
if (isNetworkAvailable()) {
coroutineScope.launch {
try {
apiService.deleteArticle(article.id)
// Only delete from local DB after server confirms
articleDao.deleteArticle(article)
} catch (e: Exception) {
Log.e("ArticleRepository", "Failed to delete article on server", e)
}
}
}
return Result.success(Unit)
}
/**
* Sync single article to server
*/
private suspend fun syncArticleToServer(article: Article) {
try {
val syncedArticle = when (article.pendingOperation) {
"CREATE" -> {
val response = apiService.createArticle(article)
// Update with server-generated ID
articleDao.deleteArticle(article)
response.copy(isSynced = true, pendingOperation = null)
}
"UPDATE" -> {
apiService.updateArticle(article.id, article)
.copy(isSynced = true, pendingOperation = null)
}
else -> return
}
articleDao.insertArticle(syncedArticle)
} catch (e: Exception) {
Log.e("ArticleRepository", "Failed to sync article", e)
// Article remains in local DB with isSynced=false
// Will be retried later
}
}
/**
* Sync all pending operations
* Called by WorkManager periodically
*/
suspend fun syncPendingOperations(): Result<Unit> {
if (!isNetworkAvailable()) {
return Result.failure(Exception("No network"))
}
try {
val pendingArticles = articleDao.getPendingOperations()
pendingArticles.forEach { article ->
syncArticleToServer(article)
}
return Result.success(Unit)
} catch (e: Exception) {
return Result.failure(e)
}
}
// Timestamp management
private suspend fun getLastSyncTimestamp(): Long {
// Retrieve from SharedPreferences or database
return 0L
}
private suspend fun updateLastSyncTimestamp(timestamp: Long) {
// Save to SharedPreferences or database
}
}
How Repository Works Internally:
The Repository acts as a mediator between the DAO and API Service:
The Flow from Room automatically updates the UI whenever database changes occur, creating a reactive data stream.
4. Sync Manager with WorkManager
WorkManager handles background synchronization, even when the app is closed.
Why WorkManager?
/**
* Worker that syncs pending operations to server
* Runs periodically in background
*/
class SyncWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val database = AppDatabase.getDatabase(applicationContext)
val repository = ArticleRepository(
articleDao = database.articleDao(),
apiService = RetrofitClient.apiService,
connectivityManager = applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager,
coroutineScope = CoroutineScope(Dispatchers.IO)
)
return when (val result = repository.syncPendingOperations()) {
is kotlin.Result.Success -> {
Log.d("SyncWorker", "Sync completed successfully")
Result.success()
}
is kotlin.Result.Failure -> {
Log.e("SyncWorker", "Sync failed", result.exception)
// Retry with exponential backoff
Result.retry()
}
}
}
}
/**
* Schedules periodic sync work
*/
object SyncScheduler {
private const val SYNC_WORK_NAME = "article_sync_work"
fun schedulePeriodicSync(context: Context) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()
val syncWorkRequest = PeriodicWorkRequestBuilder<SyncWorker>(
repeatInterval = 15, // Minimum is 15 minutes
repeatIntervalTimeUnit = TimeUnit.MINUTES
)
.setConstraints(constraints)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
WorkRequest.MIN_BACKOFF_MILLIS,
TimeUnit.MILLISECONDS
)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
SYNC_WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
syncWorkRequest
)
}
fun triggerImmediateSync(context: Context) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val syncWorkRequest = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(constraints)
.build()
WorkManager.getInstance(context).enqueue(syncWorkRequest)
}
fun cancelSync(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork(SYNC_WORK_NAME)
}
}
How WorkManager Works Internally:
WorkManager is sophisticated scheduling system:
When you enqueue work, WorkManager:
5. ViewModel — State Management
The ViewModel holds UI state and mediates between UI and Repository.
data class ArticleUiState(
val articles: List<Article> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
val isOffline: Boolean = false
)
class ArticleViewModel(
private val repository: ArticleRepository,
private val connectivityManager: ConnectivityManager
) : ViewModel() {
private val _uiState = MutableStateFlow(ArticleUiState())
val uiState: StateFlow<ArticleUiState> = _uiState.asStateFlow()
init {
// Observe articles from repository
viewModelScope.launch {
repository.articles.collect { articles ->
_uiState.update { it.copy(articles = articles) }
}
}
// Monitor connectivity
monitorConnectivity()
// Initial refresh
refreshArticles()
}
fun refreshArticles() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
when (val result = repository.refreshArticles()) {
is Result.Success -> {
_uiState.update { it.copy(isLoading = false) }
}
is Result.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
error = result.exception.message
)
}
}
}
}
}
fun createArticle(title: String, content: String, authorId: String) {
viewModelScope.launch {
repository.createArticle(title, content, authorId)
// UI updates automatically via Flow
}
}
fun updateArticle(article: Article) {
viewModelScope.launch {
repository.updateArticle(article)
}
}
fun deleteArticle(article: Article) {
viewModelScope.launch {
repository.deleteArticle(article)
}
}
private fun monitorConnectivity() {
val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
_uiState.update { it.copy(isOffline = false) }
// Trigger sync when network becomes available
refreshArticles()
}
override fun onLost(network: Network) {
_uiState.update { it.copy(isOffline = true) }
}
}
val networkRequest = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(networkRequest, networkCallback)
}
}
How ViewModel Works:
ViewModel survives configuration changes (like screen rotation) and:
The reactive pattern means the UI automatically updates when data changes, without manual refresh calls.
6. UI Layer with Jetpack Compose
Finally, the UI observes state from ViewModel and displays it.
@Composable
fun ArticleListScreen(
viewModel: ArticleViewModel = viewModel()
) {
val uiState by viewModel.uiState.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Articles") },
actions = {
// Show offline indicator
if (uiState.isOffline) {
Icon(
imageVector = Icons.Default.CloudOff,
contentDescription = "Offline",
tint = Color.Red
)
}
}
)
},
floatingActionButton = {
FloatingActionButton(
onClick = { /* Show create dialog */ }
) {
Icon(Icons.Default.Add, "Add Article")
}
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
// Pull to refresh
SwipeRefresh(
state = rememberSwipeRefreshState(uiState.isLoading),
onRefresh = { viewModel.refreshArticles() }
) {
// Article list
LazyColumn {
items(uiState.articles) { article ->
ArticleItem(
article = article,
onDelete = { viewModel.deleteArticle(article) },
modifier = Modifier.padding(16.dp)
)
}
}
}
// Show error message
uiState.error?.let { error ->
Snackbar(
modifier = Modifier.align(Alignment.BottomCenter)
) {
Text(error)
}
}
}
}
}
@Composable
fun ArticleItem(
article: Article,
onDelete: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = article.title,
style = MaterialTheme.typography.titleMedium
)
Text(
text = article.content,
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
// Show sync status
if (!article.isSynced) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 8.dp)
) {
Icon(
imageVector = Icons.Default.Sync,
contentDescription = "Syncing",
modifier = Modifier.size(16.dp),
tint = Color.Gray
)
Text(
text = "Pending sync",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
modifier = Modifier.padding(start = 4.dp)
)
}
}
}
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, "Delete")
}
}
}
}
Advanced Topics
1. Conflict Resolution
What happens when the same data is modified both locally and remotely?
Strategies:
Last Write Wins: Use timestamp to determine winner
fun resolveConflict(local: Article, remote: Article): Article {
return if (local.updatedAt > remote.updatedAt) local else remote
}
Server Wins: Always prefer remote data
fun resolveConflict(local: Article, remote: Article): Article {
return remote
}
Custom Logic: Business-specific resolution
fun resolveConflict(local: Article, remote: Article): Article {
// Merge fields based on business rules
return Article(
id = remote.id,
title = if (local.updatedAt > remote.updatedAt) local.title else remote.title,
content = remote.content, // Always use remote content
// ... other fields
)
}
2. Delta Sync
Instead of downloading all data, only fetch changes since last sync:
suspend fun deltaSync(): Result<Unit> {
val lastSync = getLastSyncTimestamp()
// Get only articles modified since last sync
val changedArticles = apiService.getArticles(since = lastSync)
// Merge with local data
changedArticles.forEach { remoteArticle ->
val localArticle = articleDao.getArticleById(remoteArticle.id)
if (localArticle != null && !localArticle.isSynced) {
// Conflict - resolve it
val resolved = resolveConflict(localArticle, remoteArticle)
articleDao.updateArticle(resolved)
} else {
// No conflict - just update
articleDao.insertArticle(remoteArticle)
}
}
updateLastSyncTimestamp(System.currentTimeMillis())
return Result.success(Unit)
}
3. Handling Large Data Sets
For apps with lots of data, use pagination:
@Dao
interface ArticleDao {
@Query("SELECT * FROM articles ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
suspend fun getArticlesPaginated(limit: Int, offset: Int): List<Article>
}
With Paging 3 library:
@Dao
interface ArticleDao {
@Query("SELECT * FROM articles ORDER BY created_at DESC")
fun getArticlesPaged(): PagingSource<Int, Article>
}
4. Data Encryption
For sensitive data, encrypt the database:
val database = Room.databaseBuilder(
context,
AppDatabase::class.java,
"encrypted_database"
)
.openHelperFactory(SupportFactory(SQLiteDatabase.getBytes("passphrase".toCharArray())))
.build()
Real-World Use Cases
Perfect for Offline-First
1. Note-Taking Apps (Evernote, Notion, Google Keep)
Why Perfect:
Real Example:
User Journey:
1. Opens app on subway (no signal) ✓ App loads instantly
2. Creates new note ✓ Saves immediately
3. Edits existing notes ✓ Updates in real-time
4. Exits subway, gets signal ✓ Syncs in background
5. Checks on laptop ✓ All notes are there
Implementation Key Points:
2. Task Management Apps (Todoist, Microsoft To Do, Any.do)
Why Perfect:
Real Example:
User Journey:
1. On morning commute (poor signal)
2. Remembers 5 tasks, adds them quickly ✓ All saved locally
3. Checks off completed tasks ✓ Updates instantly
4. Arrives at office (good WiFi) ✓ Syncs to cloud
5. Sees same tasks on desktop ✓ Perfect sync\
3. Fitness/Health Tracking (Strava, MyFitnessPal)
Why Perfect:
Real Example:
User Journey:
1. Starts run in park (spotty coverage)
2. App tracks GPS continuously ✓ All data saved locally
3. Completes run, no signal yet ✓ Workout saved
4. Returns home ✓ Auto-syncs workout & route
5. Friends see workout and comment ✓ Updates sync down
4. Reading Apps (Kindle, Pocket, Medium)
Why Perfect:
Real Example:
User Journey:
1. Downloads 10 articles while on WiFi
2. Boards 6-hour flight (airplane mode)
3. Reads all articles ✓ Perfect experience
4. Highlights passages ✓ Saved locally
5. Lands, connects ✓ Syncs reading progress & highlights
⚠️ Challenging for Offline-First
1. Social Media Feeds (Twitter, Instagram, Facebook)
Challenges:
Why Difficult:
Problems:
- Cache becomes stale quickly (feed from 2 hours ago is old news)
- Can't show "trending" content offline
- Massive storage needed for media
- Users expect fresh content on open
Workaround Strategy:
// Hybrid approach - cache intelligently
suspend fun loadFeed() {
// 1. Show cached feed immediately (last 50 posts)
val cachedPosts = feedDao.getRecentPosts(limit = 50)
_uiState.update { it.copy(posts = cachedPosts) }
// 2. Fetch fresh feed if online
if (isOnline()) {
val freshPosts = api.getFeed()
feedDao.insertPosts(freshPosts)
_uiState.update { it.copy(posts = freshPosts) }
// 3. Clean old cache
feedDao.deletePostsOlderThan(System.currentTimeMillis() - 24.hours)
}
}
2. Real-Time Collaborative Editing (Google Docs, Figma)
Challenges:
Why Difficult:
Scenario:
User A (offline): "The cat sat on the mat"
User B (online): "The dog sat on the mat"
Conflict: Which animal is correct?
Resolution: Requires complex CRDT or OT algorithms
Advanced Solution:
// Using CRDTs (Conflict-free Replicated Data Types)
data class DocumentEdit(
val characterId: UUID,
val character: Char,
val position: Int,
val timestamp: Long,
val userId: String,
val isDeleted: Boolean = false
)
// Each character has unique ID, enables conflict-free merge
fun mergeEdits(localEdits: List<DocumentEdit>, remoteEdits: List<DocumentEdit>): List<DocumentEdit> {
// Combine and sort by timestamp
return (localEdits + remoteEdits)
.sortedBy { it.timestamp }
.distinctBy { it.characterId }
}
Advantages of Offline-First System
1. Superior User Experience
Real Example — Trello:
Without Offline-First:
- User clicks "Add Card" → 2 second wait → Card appears
- User moves card → Loading spinner → Card moves
- Poor connection → Constant errors
With Offline-First:
- User clicks "Add Card" → Card appears instantly
- User moves card → Moves immediately
- No connection → Everything still works
Measured Impact:
2. Works Everywhere
Real Scenarios:
✓ Subway commute (no signal)
✓ Airplane (airplane mode)
✓ Rural areas (spotty coverage)
✓ Buildings with poor reception
✓ During internet outages
✓ In developing countries
Business Impact:
3. Reduced Server Costs
Cost Analysis:
Traditional App:
- Every screen load = API call
- 1000 users × 50 API calls/day = 50,000 requests/day
- Server costs: $500/month
Offline-First App:
- Most loads from cache
- 1000 users × 5 sync calls/day = 5,000 requests/day
- Server costs: $50/month
- 90% cost reduction!
4. Better Performance
Speed Comparison:
Data Source Load Time
─────────────────────────────────
Network (4G) 500-2000ms
Network (3G) 2000-5000ms
Network (2G) 5000-15000ms
Local Database 10-50ms
Memory Cache 1-5ms
User Perception:
Offline-first achieves “instant” feeling consistently.
5. Data Resilience
Disaster Scenarios:
Server Outage:
- Traditional app: Dead in water
- Offline-first: Works normally, syncs when back
App Crash:
- Traditional app: Might lose unsaved data
- Offline-first: Everything saved locally
Device Reboot:
- Traditional app: Must refetch everything
- Offline-first: Data persists
Disadvantages of Offline-First System
1. Increased Complexity
Code Comparison:
// Simple online-only approach (50 lines)
class ArticleViewModel(private val api: ArticleApi) {
fun loadArticles() {
viewModelScope.launch {
val articles = api.getArticles()
_articles.value = articles
}
}
}
// Offline-first approach (500+ lines)
class ArticleViewModel(private val repository: ArticleRepository) {
// Sync management
// Conflict resolution
// Connectivity monitoring
// Error handling
// Pending operations queue
// Background sync coordination
// ... much more code
}
Development Time:
2. Storage Constraints
Real Example — Photo App:
Problem:
- User has 10,000 photos
- Each photo = 3MB average
- Total = 30GB needed locally
- Most phones don't have space
Solution Required:
- Intelligent caching (keep recent/favorites)
- Download on-demand
- Purge old cache
- Compression
Storage Management:
// Must implement cache eviction
suspend fun manageCacheSize() {
val currentSize = calculateCacheSize()
val maxSize = 500.megabytes
if (currentSize > maxSize) {
// Delete least recently used items
val itemsToDelete = cacheDao.getLeastRecentlyUsed(
limit = (currentSize - maxSize) / averageItemSize
)
cacheDao.delete(itemsToDelete)
}
}
3. Conflict Resolution Complexity
Nightmare Scenario:
E-commerce App:
1. User offline, adds item to cart
2. Meanwhile, item goes out of stock online
3. User goes online, tries to checkout
4. What happens?
Resolution Needed:
- Detect conflict (item unavailable)
- Remove item from cart
- Notify user gracefully
- Suggest alternatives
Code for Conflict Handling:
suspend fun syncCart() {
val localCart = cartDao.getCart()
localCart.forEach { localItem ->
val serverItem = api.getProduct(localItem.productId)
when {
serverItem == null -> {
// Product deleted
cartDao.remove(localItem)
showNotification("${localItem.name} no longer available")
}
serverItem.stock == 0 -> {
// Out of stock
cartDao.remove(localItem)
showNotification("${localItem.name} out of stock")
}
serverItem.price != localItem.price -> {
// Price changed
val updated = localItem.copy(price = serverItem.price)
cartDao.update(updated)
showNotification("${localItem.name} price updated")
}
}
}
}
4. Data Consistency Challenges
Problem Scenario:
Banking App:
User A (offline): Transfers $100 from Account X to Account Y
User A balance locally: $900
Meanwhile on server:
Direct deposit of $500 arrives
Server balance: $1400
User A goes online:
Local says: $900
Server says: $1400
Which is correct? How to merge?
Why This is Hard:
// Can't just use "last write wins"
fun mergeBalance(local: Balance, remote: Balance): Balance {
// WRONG: return if (local.timestamp > remote.timestamp) local else remote
// MUST track individual transactions
val localTransactions = getLocalTransactionsSince(lastSync)
val remoteTransactions = getRemoteTransactionsSince(lastSync)
// Apply all transactions to get correct balance
return calculateBalanceFromTransactions(
lastSyncedBalance,
localTransactions,
remoteTransactions
)
}
5. Battery and Resource Usage
Background Sync Impact:
Measurements:
- WorkManager sync every 15 min = 96 syncs/day
- Each sync = 5 seconds of work
- Total = 8 minutes of background CPU/day
- Battery impact: 3-5% per day
User Impact:
- Might disable background sync
- Might uninstall if battery drain high
- Need careful optimization
Optimization Required:
// Batch operations to minimize wake-ups
val syncConstraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true) // Don't sync on low battery
.setRequiresCharging(false) // Optional: only sync when charging
.build()
// Use exponential backoff if syncs fail
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
15.minutes.inWholeMilliseconds,
TimeUnit.MILLISECONDS
)
Decision Framework: Should You Build Offline-First?
Use This Scoring System:
Score each question (0–10):
2. User Expectations: How critical is instant response?
3. Data Volatility: How quickly does data become stale?
4. Conflict Likelihood: How often will conflicts occur?
5. Data Size: How much data needs caching?
Scoring:
Summary
Building an offline-first system is like designing a ship that works perfectly whether connected to the dock or sailing the open ocean. It requires careful planning, robust architecture, and thoughtful user experience design. The investment pays off in happier users who can rely on your app anywhere, anytime.
The key is to think of your local database as the source of truth, treat the network as an enhancement, and always prioritize the user experience. With Room, WorkManager, and modern Android architecture components, building offline-first apps has never been more accessible.
Remember: the best apps are those that work when users need them most, and that’s often when they’re offline.
Thank you for reading. 🙌🙏✌.
Need 1:1 Career Guidance or Mentorship?
If you’re looking for personalized guidance, interview preparation help, or just want to talk about your career path in mobile development — you can book a 1:1 session with me on Topmate.
I’ve helped many developers grow in their careers, switch jobs, and gain clarity with focused mentorship. Looking forward to helping you too!
Found this helpful? Don’t forgot to clap 👏 and follow me for more such useful articles about Android development and Kotlin or buy us a coffee here ☕
Crack Android Interviews Like a Pro
Your complete Android interview preparation book — packed with real questions, deep explanations, and practical insights to help you stand out. 👉 Grab your copy now: https://medium.com/@anandgaur2207/crack-android-interviews-with-confidence-the-only-handbook-youll-need-b87ec525f19c
If you need any help related to Mobile app development. I’m always happy to help you.
Follow me on:
I am very excited for this job and immediately join
Room DB can't handle large amounts of data, for large amounts data SQLite is better option.