Mobile Offline Architecture: Building Apps That Work Without Internet
Design mobile applications that function seamlessly offline, syncing data when connectivity returns. Covers local database strategies, conflict resolution, sync protocols, queue-based architectures, and the UX patterns that make offline-first apps feel natural.
Users do not stop using apps when they enter a subway, board a plane, or walk into a building with poor signal. An offline-capable app handles these transitions gracefully — queuing actions, serving cached data, and syncing when connectivity returns. An online-only app shows a spinner, then an error, then loses the user.
Offline architecture is not about edge cases. It is about the 30% of mobile usage that happens on unreliable connections.
Offline Architecture Patterns
Read-Only Offline (Cache First)
The simplest pattern: cache API responses and serve them when offline.
// iOS: Cache then network
func fetchOrders() async -> [Order] {
// Always return cached data first
let cached = localDB.getOrders()
// Then attempt network refresh
if let fresh = try? await api.getOrders() {
localDB.saveOrders(fresh)
return fresh
}
return cached
}
Use when: Content is primarily server-generated and does not change frequently (news, catalogs, documentation).
Read-Write Offline (Queue and Sync)
Users can create and modify data offline. Changes are queued and synced when connectivity returns:
// Android: Offline action queue
data class PendingAction(
val id: String = UUID.randomUUID().toString(),
val type: ActionType,
val payload: String,
val createdAt: Long = System.currentTimeMillis(),
val retryCount: Int = 0
)
class OfflineQueue(private val db: AppDatabase) {
suspend fun enqueue(action: PendingAction) {
db.pendingActionDao().insert(action)
}
suspend fun sync() {
val pending = db.pendingActionDao().getAll()
for (action in pending) {
try {
api.execute(action)
db.pendingActionDao().delete(action)
} catch (e: Exception) {
if (action.retryCount >= MAX_RETRIES) {
db.pendingActionDao().markFailed(action.id)
} else {
db.pendingActionDao().incrementRetry(action.id)
}
}
}
}
}
Use when: Users need to create or edit data in areas with poor connectivity (field service, delivery, healthcare).
Offline-First (Local Database as Source of Truth)
The local database is the primary data store. The server is a sync target:
Local DB (SQLite/Realm) ←→ Sync Engine ←→ Server API
↕ ↕
App UI Other Devices
All reads and writes go to the local database. The sync engine handles bidirectional synchronization in the background.
Use when: The app must feel instant regardless of connectivity (note-taking, task management, CRM).
Local Database Selection
| Database | Platform | Strengths |
|---|---|---|
| SQLite (Room/GRDB) | Both | Standard, SQL queries, lightweight |
| Realm | Both | Object-oriented, reactive, built-in sync |
| Core Data | iOS | Apple ecosystem integration, iCloud sync |
| WatermelonDB | React Native | Fast, observable, sync-friendly |
Schema Design for Sync
Add sync metadata to every table:
CREATE TABLE orders (
id TEXT PRIMARY KEY,
customer_id TEXT NOT NULL,
total REAL NOT NULL,
status TEXT NOT NULL,
-- Sync metadata
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
synced_at INTEGER, -- NULL = never synced
is_deleted INTEGER DEFAULT 0, -- Soft delete for sync
version INTEGER DEFAULT 1 -- Conflict detection
);
Conflict Resolution
When two devices modify the same record offline, a conflict occurs. There are no magic solutions — only trade-offs:
Last Write Wins (LWW)
The most recent timestamp wins:
def resolve_conflict(local, remote):
if remote.updated_at > local.updated_at:
return remote # Server wins
else:
return local # Local wins
Pros: Simple, deterministic. Cons: Silent data loss — the “loser” edit is discarded.
Field-Level Merge
Merge non-conflicting changes at the field level:
def merge_fields(local, remote, base):
result = {}
for field in all_fields:
local_changed = getattr(local, field) != getattr(base, field)
remote_changed = getattr(remote, field) != getattr(base, field)
if local_changed and not remote_changed:
result[field] = getattr(local, field)
elif remote_changed and not local_changed:
result[field] = getattr(remote, field)
elif local_changed and remote_changed:
# True conflict — need strategy
if getattr(local, field) == getattr(remote, field):
result[field] = getattr(local, field) # Same change
else:
result[field] = resolve_field_conflict(field, local, remote)
return result
User-Mediated Resolution
Present the conflict to the user:
┌─────────────────────────────────────┐
│ Conflict detected on "Order #456" │
│ │
│ Your version: Status = "Shipped" │
│ Server version: Status = "Cancelled"│
│ │
│ [Keep Mine] [Keep Theirs] [Merge] │
└─────────────────────────────────────┘
Sync Protocols
Delta Sync
Only send changes since the last sync:
Client → Server: GET /api/sync?since=2026-03-04T15:00:00Z
Server → Client: { changes: [...], server_time: "2026-03-04T15:05:00Z" }
Client → Server: POST /api/sync
Body: { changes: [...], base_time: "2026-03-04T15:00:00Z" }
Operational Transformation
Instead of syncing state, sync operations:
Operation log:
1. create_order(id=456, items=[A, B])
2. update_order(id=456, add_item=C)
3. update_order(id=456, set_status="confirmed")
Operations are more composable than state snapshots and enable fine-grained conflict resolution.
UX Patterns for Offline
Connectivity Indicators
Online: No indicator (normal state, should not draw attention)
Slow: Subtle indicator (yellow dot, "limited connectivity")
Offline: Clear indicator (banner, "you're offline — changes will sync")
Syncing: Activity indicator (spinning icon, "syncing 3 changes")
Sync error: Actionable error ("3 changes could not be synced — tap to retry")
Optimistic UI
Apply changes to the UI immediately, before the server confirms:
func addItemToCart(_ item: Item) {
// 1. Update UI immediately
cart.items.append(item)
updateCartUI()
// 2. Queue for server sync
offlineQueue.enqueue(.addToCart(item))
// 3. Handle sync failure later
// If sync fails, show a non-disruptive error
}
Anti-Patterns
| Anti-Pattern | Consequence | Fix |
|---|---|---|
| Loading spinner on every request | App feels broken offline | Cache-first with background refresh |
| Silently dropping offline actions | User loses work | Queue and sync with conflict resolution |
| Storing everything locally | Storage bloat, slow queries | Cache with eviction policy |
| No sync indicator | Users do not know data is stale | Subtle “last synced X ago” indicator |
| Full data re-download on sync | Slow, bandwidth-heavy | Delta sync with timestamps |
Offline-first is not a feature — it is an architecture. Retrofitting offline support onto an app designed for always-on connectivity is painful and incomplete. Design for offline from day one, and online becomes a happy bonus that makes sync faster.