ESC
Type to search guides, tutorials, and reference documentation.
Verified by Garnet Grid

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-PatternConsequenceFix
Loading spinner on every actionApp feels broken offlineOptimistic UI with local data
No local databaseComplete failure without networkSQLite/Room/Realm for local persistence
Global last-write-winsSilent data lossField-level merge or user resolution
No sync retryFailed syncs lost foreverPersistent sync queue with exponential backoff
Ignoring storage limitsApp crashes when offline cache growsData 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.

Jakub Dimitri Rezayev
Jakub Dimitri Rezayev
Founder & Chief Architect • Garnet Grid Consulting

Jakub holds an M.S. in Customer Intelligence & Analytics and a B.S. in Finance & Computer Science from Pace University. With deep expertise spanning D365 F&O, Azure, Power BI, and AI/ML systems, he architects enterprise solutions that bridge legacy systems and modern technology — and has led multi-million dollar ERP implementations for Fortune 500 supply chains.

View Full Profile →