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

Mobile Offline-First Architecture

Build mobile applications that work reliably without network connectivity. Covers offline data storage, sync strategies, conflict resolution, queue-based operations, optimistic UI, and the patterns that make offline-first feel seamless rather than broken.

Mobile networks are unreliable. Elevators, subways, rural areas, airplane mode — your users will be offline. An app that shows a spinner or error when the network drops is an app that fails its users. Offline-first architecture treats the network as an enhancement, not a requirement.


Offline-First Principles

Principle 1: Local First
  Read from local database, sync in background
  User never waits for network for reads
  
Principle 2: Optimistic Writes
  Write to local database immediately
  Queue changes for sync when online
  User sees instant feedback
  
Principle 3: Eventual Consistency
  Local and remote may differ temporarily
  Sync resolves differences automatically
  Conflicts handled by merge strategy

Principle 4: Graceful Degradation
  Full functionality offline (reads + writes)
  Reduced functionality only for truly online features
  Clear UI indication of sync status

Architecture

┌─────────────────────────────────┐
│          Mobile App             │
│                                 │
│  ┌──────────┐  ┌─────────────┐ │
│  │  UI Layer │  │ Sync Engine │ │
│  └────┬─────┘  └──────┬──────┘ │
│       │               │         │
│  ┌────▼────────────────▼──────┐ │
│  │      Local Database         │ │
│  │  (SQLite / Realm / MMKV)   │ │
│  └────────────┬───────────────┘ │
└───────────────┼─────────────────┘
                │   ↕ Sync (when online)
        ┌───────▼───────┐
        │  Remote API   │
        │   (Server)    │
        └───────────────┘

Sync Engine

class SyncEngine {
    let localDB: LocalDatabase
    let remoteAPI: RemoteAPI
    let conflictResolver: ConflictResolver
    
    // Outbound sync: Push local changes to server
    func pushPendingChanges() async throws {
        let pendingOps = localDB.getPendingOperations()
        
        for op in pendingOps {
            do {
                switch op.type {
                case .create:
                    let serverRecord = try await remoteAPI.create(op.data)
                    localDB.updateServerID(localID: op.localID, serverID: serverRecord.id)
                    
                case .update:
                    try await remoteAPI.update(op.serverID, data: op.data)
                    
                case .delete:
                    try await remoteAPI.delete(op.serverID)
                }
                
                localDB.markSynced(op.id)
                
            } catch let error as ConflictError {
                // Server has a newer version
                let resolved = conflictResolver.resolve(
                    local: op.data,
                    remote: error.serverVersion,
                    strategy: .lastWriterWins  // or .merge, .userChoice
                )
                localDB.applyResolution(op.id, resolved)
            }
        }
    }
    
    // Inbound sync: Pull server changes to local
    func pullRemoteChanges() async throws {
        let lastSync = localDB.getLastSyncTimestamp()
        let changes = try await remoteAPI.getChangesSince(lastSync)
        
        for change in changes {
            if let localVersion = localDB.get(serverID: change.id) {
                if localVersion.modifiedAt > change.modifiedAt {
                    // Local is newer — skip (will be pushed next)
                    continue
                }
            }
            
            localDB.upsert(change)
        }
        
        localDB.setLastSyncTimestamp(Date())
    }
}

Conflict Resolution

Last Writer Wins (LWW):
  Simple: Most recent timestamp wins
  Pro: Easy to implement, no user intervention
  Con: Data loss if both sides changed different fields
  
  Use for: Settings, preferences, simple records

Field-Level Merge:
  Smart: Merge non-conflicting field changes
  Pro: Preserves changes from both sides
  Con: Complex implementation
  
  Use for: Documents, forms with multiple fields

User Choice:
  Manual: Show both versions, let user decide
  Pro: No data loss, user in control
  Con: Interrupts user flow
  
  Use for: Critical data, rare conflicts

Operational Transform / CRDT:
  Automatic: Mathematically guaranteed convergence
  Pro: No conflicts possible, real-time collaboration
  Con: Limited data types, complex implementation
  
  Use for: Collaborative editing, shared lists

Anti-Patterns

Anti-PatternConsequenceFix
Online-first with offline fallbackOffline feels broken, bolted-onDesign offline-first from day one
No sync status indicatorUser unsure if data is savedClear sync badges (✓ synced, ⟳ pending, ! failed)
Sync on app launch onlyStale data between sessionsBackground sync + push notifications
No conflict resolutionConflicts silently overwrite dataExplicit conflict strategy per data type
Store everything locallyStorage bloat, slow queriesSync only needed data, paginate history

Offline-first is not about caching — it is about making the local database the primary data source and the server a synchronization target. When done right, users cannot tell the difference between online and offline.

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 →