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-Pattern | Consequence | Fix |
|---|---|---|
| Online-first with offline fallback | Offline feels broken, bolted-on | Design offline-first from day one |
| No sync status indicator | User unsure if data is saved | Clear sync badges (✓ synced, ⟳ pending, ! failed) |
| Sync on app launch only | Stale data between sessions | Background sync + push notifications |
| No conflict resolution | Conflicts silently overwrite data | Explicit conflict strategy per data type |
| Store everything locally | Storage bloat, slow queries | Sync 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.