Offline-first Android apps: patterns, pitfalls and fixes
Building an Android app that works seamlessly without internet connectivity is no longer a nice-to-have—it's an expectation. Users expect to keep working, browsing cached content, and queuing actions for when the connection returns. The challenge is that offline-first Android apps require a different architectural mindset: treating local data as the single source of truth, syncing intelligently in the background, and gracefully handling conflicts when the server and device disagree. This article walks through the key patterns we've used in production e-commerce and SaaS apps, the pitfalls we've hit, and the fixes that actually work.
Local database as source of truth — UI reads from Room
The first principle of offline-first architecture is that your UI never queries the network directly. Instead, the UI observes a local database—typically Room in Android. When you need fresh data, background jobs fetch it from the server and write it to Room; the UI automatically updates via reactive flows.
This solves two problems: the UI remains responsive even on slow connections, and the app works when there's no connection at all.
Here's a simplified example. Say we're building an e-commerce app and need to display a list of orders:
@Entity(tableName = "orders")
data class OrderEntity(
@PrimaryKey val id: String,
val userId: String,
val total: Double,
val status: String,
val createdAt: Long,
val syncedAt: Long? = null,
val pendingDelete: Boolean = false
)
@Dao
interface OrderDao {
@Query("SELECT * FROM orders WHERE userId = :userId AND pendingDelete = 0 ORDER BY createdAt DESC")
fun getOrdersByUser(userId: String): Flow<List<OrderEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(order: OrderEntity)
@Query("UPDATE orders SET pendingDelete = 1 WHERE id = :orderId")
suspend fun markForDeletion(orderId: String)
}
The repository layer orchestrates reads and writes:
class OrderRepository(
private val orderDao: OrderDao,
private val orderApi: OrderApi
) {
fun observeOrders(userId: String): Flow<List<Order>> =
orderDao.getOrdersByUser(userId)
.map { entities ->
entities.map { it.toDomain() }
}
suspend fun refreshOrders(userId: String) {
try {
val remote = orderApi.getOrders(userId)
remote.forEach { dto ->
orderDao.upsert(
OrderEntity(
id = dto.id,
userId = userId,
total = dto.total,
status = dto.status,
createdAt = dto.createdAt,
syncedAt = System.currentTimeMillis()
)
)
}
} catch (e: Exception) {
// Network error; UI continues showing cached data
Timber.w(e, "Failed to refresh orders")
}
}
}
The ViewModel or composable subscribes to this flow and renders what's there—cached or fresh. No conditional logic needed in the UI layer:
class OrderListViewModel(private val repo: OrderRepository) : ViewModel() {
val orders: Flow<List<Order>> = repo.observeOrders(userId = "user123")
fun refresh() {
viewModelScope.launch {
repo.refreshOrders("user123")
}
}
}
This pattern keeps your UI simple and ensures offline users see stale data rather than an error screen. The catch is that you must keep your Room schema in sync with your API contracts, and you need disciplined migrations as the app evolves.
Sync in the background — WorkManager pushes and pulls changes
Fetching data on demand works for reads, but what about writes? When a user places an order offline, we need to queue it and send it when the connection returns. WorkManager is the right tool: it respects battery and data-saver policies, retries intelligently, and survives app restarts.
The pattern is to mark local changes with a sync flag or status column, then schedule a background job to push them up when connectivity allows:
@Entity(tableName = "orders")
data class OrderEntity(
@PrimaryKey val id: String,
val userId: String,
val total: Double,
val status: String,
val createdAt: Long,
val syncedAt: Long? = null,
val pendingSync: Boolean = true, // Marks unsync'd changes
val pendingDelete: Boolean = false
)
@Dao
interface OrderDao {
@Query("SELECT * FROM orders WHERE userId = :userId AND pendingSync = 1")
suspend fun getPendingOrders(userId: String): List<OrderEntity>
@Query("UPDATE orders SET pendingSync = 0, syncedAt = :now WHERE id = :orderId")
suspend fun markSynced(orderId: String, now: Long = System.currentTimeMillis())
}
The background worker handles the actual sync:
class OrderSyncWorker(
context: Context,
params: WorkerParameters,
private val orderDao: OrderDao,
private val orderApi: OrderApi,
private val userId: String
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
try {
val pending = orderDao.getPendingOrders(userId)
pending.forEach { order ->
try {
orderApi.createOrder(
CreateOrderRequest(
id = order.id,
total = order.total
)
)
orderDao.markSynced(order.id)
} catch (e: Exception) {
Timber.w(e, "Failed to sync order ${order.id}, will retry")
throw e // Cause WorkManager to retry
}
}
Result.success()
} catch (e: Exception) {
Timber.e(e, "OrderSyncWorker failed")
Result.retry() // Exponential backoff
}
}
}
Wire it up with a connectivity observer to trigger syncs when the network returns:
class SyncManager(private val workManager: WorkManager) {
fun scheduleSyncWork(userId: String) {
val syncRequest = OneTimeWorkRequestBuilder<OrderSyncWorker>()
.setInputData(
workDataOf("userId" to userId)
)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
15L,
TimeUnit.MINUTES
)
.addTag("order_sync_$userId")
.build()
workManager.enqueueUniqueWork(
"order_sync_$userId",
ExistingWorkPolicy.KEEP, // Don't enqueue duplicate
syncRequest
)
}
}
Monitor connectivity using ConnectivityManager callbacks (or androidx.work.Constraints) to trigger syncs automatically when WiFi or mobile returns. This ensures users don't have to manually refresh, and pending work happens quietly in the background.
Conflict handling — Timestamps or server rules decide
When a device and server modify the same record while offline, you have a conflict. There's no universal solution—it depends on your domain. E-commerce orders rarely conflict (new orders are always wins). CRM records conflict constantly (both client and support agent edit the same contact). For most cases, last-write-wins (compare timestamps) works, but some domains need server-authority or field-level merging.
Add a version or timestamp to your schema:
@Entity(tableName = "contacts")
data class ContactEntity(
@PrimaryKey val id: String,
val name: String,
val email: String,
val phone: String,
val updatedAt: Long,
val updatedLocallyAt: Long? = null,
val syncedAt: Long? = null
)
When pulling changes from the server, apply a conflict rule:
suspend fun mergeContact(remote: ContactDto, userId: String) {
val local = contactDao.getContact(remote.id) ?: return run {
// New remote contact
contactDao.insert(remote.toEntity())
}
val merged = when {
// Server is newer: take it
remote.updatedAt > local.updatedAt -> {
remote.toEntity().copy(syncedAt = System.currentTimeMillis())
}
// Local changes are newer: keep local, queue for sync
(local.updatedLocallyAt ?: 0) > remote.updatedAt -> {
local.copy(syncedAt = System.currentTimeMillis())
}
// Equal or too close to call: server wins
else -> {
remote.toEntity().copy(syncedAt = System.currentTimeMillis())
}
}
contactDao.upsert(merged)
}
For write conflicts (device and server both changed the same field), decide upfront: does the server always win, or does the client? Document this clearly in your sync logic. In high-stakes apps (financial, healthcare), explicit conflict screens for users are better than silent overwrites.
Show sync status — Let users know what is pending
Users hate mysterious behavior. If their order is queued but not sent, they should see that. Build a sync status indicator into your Room model and expose it to the UI:
data class OrderWithSyncStatus(
@Embedded val order: OrderEntity,
val syncState: SyncState
)
enum class SyncState {
SYNCED,
PENDING,
IN_PROGRESS,
ERROR
}
Update the state as workers run:
@Query("""
SELECT o.*,
CASE
WHEN o.pendingDelete = 1 THEN 'PENDING'
WHEN o.pendingSync = 1 THEN 'PENDING'
WHEN o.syncedAt IS NOT NULL THEN 'SYNCED'
ELSE 'ERROR'
END as syncState
FROM orders o
WHERE o.userId = :userId
ORDER BY o.createdAt DESC
""")
fun getOrdersWithStatus(userId: String): Flow<List<OrderWithSyncStatus>>
Display this in your UI—a small icon, badge, or message. In a Compose example:
@Composable
fun OrderItem(order: OrderWithSyncStatus) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(order.order.id, style = MaterialTheme.typography.bodyMedium)
Text("$${order.order.total}", style = MaterialTheme.typography.bodyLarge)
}
when (order.syncState) {
SyncState.SYNCED -> Icon(Icons.Default.Done, contentDescription = "Synced")
SyncState.PENDING -> Icon(Icons.Default.Schedule, contentDescription = "Pending sync")
SyncState.IN_PROGRESS -> CircularProgressIndicator()
SyncState.ERROR -> Icon(Icons.Default.Error, contentDescription = "Sync error")
}
}
}
This transparency is crucial. Users who know their action is pending offline are calm; users who don't know are confused and frustrated.
Common mistakes
- Querying the network in the UI layer. This breaks offline-first. Always read from Room; let background jobs fetch and update Room. Your UI observes Room flows only.
- No conflict resolution strategy. If you don't decide how to handle conflicts before they happen, you'll have data loss or inconsistency. Document and test your rules.
- Forgetting about deletions. Soft deletes (marking pendingDelete = true) are essential in offline apps. You can't send a DELETE API call if you're offline, so queue it locally first.
- Not retrying sync work intelligently. WorkManager's exponential backoff is your friend. Don't give up after one failure; the network hiccup might be temporary.
- Hiding sync errors from users. Silent failures lead to data loss beliefs. Always show sync status, especially errors. Let users retry manually if needed.
- Ignoring schema migrations. Room migrations are easy to neglect until they break production. Plan them upfront, especially for offline-first apps where user data is precious.
Key takeaways
- Make Room your single source of truth; the UI reads from Room flows, never directly from the network.
- Use WorkManager to queue writes (orders, edits) and schedule syncs when connectivity returns; handle retries with exponential backoff.
- Define a conflict resolution strategy early—last-write-wins via timestamps works for many domains, but some need server authority or custom logic.
- Expose sync status (synced, pending, in progress, error) to the UI so users understand what's happening behind the scenes.
- Always soft-delete records locally first (mark pendingDelete = true), then sync the deletion when online; hard-deletes before sync cause data loss.
- Plan Room schema migrations carefully; offline-first apps often hold stale data longer, so migrations must be robust.






Comments
Loading comments…