Offline-First Mobile Design
Build mobile apps that work reliably without a network connection. Covers local-first data, sync strategies, conflict resolution, optimistic UI, and the patterns that make offline-first feel seamless to users.
Users do not care about your network architecture. They care that the app works — on the subway, on a plane, in a basement with no signal. Offline-first design treats network connectivity as an enhancement, not a requirement. The app works with local data by default and syncs when connectivity is available.
Offline-First Principles
1. Local data is the source of truth
App reads from and writes to local database first
2. Sync is asynchronous and retry-resilient
Changes queue locally and sync when connected
3. UI is optimistic
Show the result immediately, sync in the background
4. Conflicts are inevitable
Design a conflict resolution strategy before you start
5. Network is a progressive enhancement
App is fully functional offline, enhanced when online
Data Architecture
User Action
↓
Local Database (SQLite / Realm / Room)
↓ (immediate response)
UI Update (optimistic)
↓ (background)
Sync Queue
↓ (when connected)
Server API
↓ (response)
Local Database (merge server changes)
↓
UI Update (if changed)
Local Database
// Room (Android) - Local-first data access
@Dao
interface OrderDao {
@Query("SELECT * FROM orders ORDER BY created_at DESC")
fun getAllOrders(): Flow<List<OrderEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(orders: List<OrderEntity>)
@Query("SELECT * FROM orders WHERE sync_status = 'PENDING'")
suspend fun getPendingSync(): List<OrderEntity>
@Update
suspend fun markSynced(order: OrderEntity)
}
Sync Queue
class SyncManager(
private val db: AppDatabase,
private val api: ApiService,
private val connectivity: ConnectivityManager
) {
suspend fun sync() {
if (!connectivity.isConnected()) return
// 1. Push local changes
val pending = db.orderDao().getPendingSync()
for (order in pending) {
try {
api.syncOrder(order.toRequest())
db.orderDao().markSynced(order.copy(syncStatus = SYNCED))
} catch (e: ConflictException) {
resolveConflict(order, e.serverVersion)
}
}
// 2. Pull server changes
val lastSync = db.syncMeta().getLastSyncTimestamp()
val serverChanges = api.getChangesSince(lastSync)
db.orderDao().insertAll(serverChanges.map { it.toEntity() })
db.syncMeta().updateLastSync(Clock.System.now())
}
}
Conflict Resolution
Last-Write-Wins (LWW)
Client A: Update name = "Alice" at T1
Client B: Update name = "Bob" at T2
T2 > T1, so "Bob" wins.
Pros: Simple, deterministic
Cons: Data loss (Alice's change is lost)
Field-Level Merge
Client A: Update name = "Alice" at T1
Client B: Update email = "bob@example.com" at T2
Merge: name = "Alice", email = "bob@example.com"
Pros: No data loss for non-conflicting fields
Cons: Complex to implement for nested data
User Resolution
When conflict detected:
Show both versions to user
"Your version" vs "Server version"
User chooses which to keep (or merges manually)
Optimistic UI
fun createOrder(order: Order) {
// 1. Show in UI immediately
_orders.update { it + order.copy(status = PENDING) }
// 2. Save to local DB with pending status
db.orderDao().insert(order.toEntity(syncStatus = PENDING))
// 3. Attempt sync in background
viewModelScope.launch {
try {
val serverOrder = api.createOrder(order)
db.orderDao().markSynced(serverOrder.toEntity())
_orders.update { list ->
list.map { if (it.localId == order.localId) serverOrder else it }
}
} catch (e: Exception) {
// Order stays in pending state, will retry on next sync
_orders.update { list ->
list.map {
if (it.localId == order.localId) it.copy(status = SYNC_FAILED)
else it
}
}
}
}
}
Anti-Patterns
| Anti-Pattern | Consequence | Fix |
|---|---|---|
| Loading spinner on every action | App feels broken offline | Optimistic UI with local data |
| No local database | Complete failure without network | SQLite/Room/Realm for local persistence |
| Global last-write-wins | Silent data loss | Field-level merge or user resolution |
| No sync retry | Failed syncs lost forever | Persistent sync queue with exponential backoff |
| Ignoring storage limits | App crashes when offline cache grows | Data eviction policies, storage budgets |
Offline-first is not optional for mobile apps. Users expect apps to work everywhere. The network condition should change the speed of sync, not the functionality of the app.