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

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

DatabasePlatformStrengths
SQLite (Room/GRDB)BothStandard, SQL queries, lightweight
RealmBothObject-oriented, reactive, built-in sync
Core DataiOSApple ecosystem integration, iCloud sync
WatermelonDBReact NativeFast, 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-PatternConsequenceFix
Loading spinner on every requestApp feels broken offlineCache-first with background refresh
Silently dropping offline actionsUser loses workQueue and sync with conflict resolution
Storing everything locallyStorage bloat, slow queriesCache with eviction policy
No sync indicatorUsers do not know data is staleSubtle “last synced X ago” indicator
Full data re-download on syncSlow, bandwidth-heavyDelta 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.

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 →