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.

Mobile apps fail when the network is slow, intermittent, or absent. Offline architecture ensures that users can create, view, and update data even without connectivity, with changes synchronising seamlessly when the network returns. This is essential for field services, remote healthcare, logistics, and any app where users operate in cafes, trains, or rural areas.

Designing for Offline-First Data Flow

Data must be available locally and synchronised with remote systems. The core pattern is client-side storage, change tracking, and reconciliation. A client stores data in a local database (SQLite, Realm, or a file-based JSON store), tracks local changes, and uploads them when connectivity resumes.

When the app starts, it loads data from the local store. All user actions—creating a task, updating a profile, uploading a photo—write to the local database immediately. The app records each change with a localId, timestamp, and status: pending | synced | failed.

The sync engine runs on network events. On startup, it checks for connectivity and triggers sync if the network is available. Sync is a multi-stage process:

  1. Fetch: Retrieve remote changes since the last sync.
  2. Apply: Merge remote changes into the local store.
  3. Push: Send local changes to the remote server.
  4. Finalize: Update sync metadata and handle merge conflicts.

Each stage must be idempotent and retryable.

Tracking Changes with a Sync Log

Each change is recorded in a sync_log table with the following schema:

CREATE TABLE sync_log (
    id INTEGER PRIMARY KEY,
    local_id TEXT NOT NULL,
    operation TEXT CHECK(operation IN ('create', 'update', 'delete')),
    entity_type TEXT NOT NULL,
    entity_id TEXT,
    payload JSON,
    status TEXT DEFAULT 'pending',
    attempted_at DATETIME,
    completed_at DATETIME,
    error TEXT,
    version INTEGER NOT NULL
);

The version column tracks the state of the local database. When the app starts, it reads the last_sync_version from a settings table and queries all changes with version > last_sync_version.

Sync Engine: A Sequential Pipeline

The sync engine is a state machine with three phases: idle, connecting, syncing, finalizing.

On network arrival, the app runs:

curl -X POST http://api.example.com/v1/sync \
  -H "Authorization: Bearer ${token}" \
  -H "Content-Type: application/json" \
  -d '{"lastSyncVersion": 142}' \
  -w "%{http_code}\n"

The server returns a 200 with a JSON body:

{
  "changes": [
    {
      "id": "task-103",
      "type": "update",
      "data": {
        "title": "Review Q4 budget",
        "completed": true
      },
      "updatedAt": "2024-04-15T10:32:11Z"
    }
  ],
  "nextSyncVersion": 145,
  "lastSyncAt": "2024-04-15T10:32:11Z"
}

The client applies these changes to the local database and marks them as synced. Then, it collects all pending entries from sync_log and sends them in a batch:

[
  {
    "operation": "create",
    "entityType": "task",
    "entityId": "task-104",
    "payload": {
      "title": "Submit travel report",
      "assignee": "[email protected]"
    },
    "timestamp": "2024-04-15T10:33:00Z"
  },
  {
    "operation": "update",
    "entityType": "profile",
    "entityId": "profile-5",
    "payload": {
      "name": "Alice Chen",
      "phone": "+1-555-123-4567"
    },
    "timestamp": "2024-04-15T10:33:05Z"
  }
]

The server returns a 200 with an array of syncedIds and failedIds.

The final phase updates last_sync_version and marks all changes as completed.

Handling Conflicts in Concurrent Edits

When two clients modify the same record while offline, a merge conflict arises. The client detects this during the apply stage of sync.

Each record includes a version number and updatedAt timestamp. When a client updates a task, it sends:

{
  "id": "task-103",
  "title": "Review Q4 budget",
  "completed": true,
  "version": 142,
  "updatedAt": "2024-04-15T10:32:11Z"
}

The server checks if the version matches the current version of the record. If not, it returns:

{
  "status": "conflict",
  "conflict": {
    "localVersion": 142,
    "remoteVersion": 144,
    "localData": {
      "title": "Review Q4 budget",
      "completed": true
    },
    "remoteData": {
      "title": "Review Q4 budget",
      "completed": true,
      "priority": "high"
    }
  }
}

The client then presents a merge UI or applies a merge strategy:

The resolved record is saved locally with a new version and sent back to the server.

Offline-First UI: Seamless User Experience

A successful offline app feels instant. Users do not perceive delays or network hops.

The app starts with a cached state. If no network is available, it shows the last known data and marks the UI as offline. A banner at the top displays the status: Offline, Syncing, Synced, Sync failed.

On user actions, the app updates the UI immediately and displays a syncing... spinner. When changes are committed locally, it shows a ✓ next to the action.

The app polls for network changes using the navigator.onLine API. It listens for online and offline events:

window.addEventListener('online', () => {
  syncEngine.start();
});

window.addEventListener('offline', () => {
  syncEngine.pause();
});

The sync engine runs on a Background Sync service. On iOS, it uses NSURLSession with backgroundFetch and backgroundProcessing. On Android, it uses WorkManager with PeriodicWorkRequest and Constraint.

The sync_engine is configured in a config.json:

{
  "syncInterval": 60000,
  "maxRetries": 3,
  "retryBackoff": 1.5,
  "batchSize": 50,
  "mergeStrategy": "lastWriteWins",
  "conflictResolution": {
    "strategy": "mergeFields",
    "fields": ["title", "description", "priority"],
    "resolve": "userDefined"
  },
  "networkConditions": [
    "wifi",
    "cellular",
    "lowBandwidth"
  ]
}

Storage: Choosing the Right Local Database

The choice of storage affects performance, durability, and complexity.

For apps with complex data models and relationships, use a relational database. SQLite is the standard across platforms. On Android, it is available in android.database.sqlite. On iOS, Core Data is common, but SQLite is used directly via FMDB or GRDB.

On iOS, Core Data is configured with a xcdatamodeld file. The NSPersistentContainer is loaded on app start:

let container = NSPersistentContainer(name: "AppModel")
container.loadPersistentStores { _, error in
    if let error = error {
        fatalError("Failed to load Core Data stack: \(error)")
    }
}

On Android, Room is the recommended ORM:

@Dao
interface TaskDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(task: Task)

    @Update
    suspend fun update(task: Task)

    @Query("SELECT * FROM task WHERE syncStatus = 'pending'")
    suspend fun getPendingTasks(): List<Task>
}

For simpler apps, use a file-based JSON store. The app serializes objects to app_state.json:

{
  "tasks": {
    "task-103": {
      "id": "task-103",
      "title": "Review Q4 budget",
      "completed": true,
      "updatedAt": "2024-04-15T10:32:11Z",
      "syncStatus": "synced"
    }
  },
  "profiles": {
    "profile-5": {
      "id": "profile-5",
      "name": "Alice Chen",
      "phone": "+1-555-123-4567"
    }
  },
  "lastSyncVersion": 145,
  "lastSyncAt": "2024-04-15T10:32:11Z"
}

On startup, the app reads this file and applies it to the state. On save, it writes it back.

Error Handling and Silent Failures

Offline apps fail silently. The most common failure is sync not running, even when network is available.

The sync engine logs every operation. A sync_error table records:

CREATE TABLE sync_error (
    id INTEGER PRIMARY KEY,
    sync_log_id INTEGER,
    message TEXT NOT NULL,
    code TEXT,
    timestamp DATETIME,
    retryCount INTEGER DEFAULT 0,
    FOREIGN KEY (sync_log_id) REFERENCES sync_log(id)
);

Common errors:

The sync engine retries failed operations with exponential backoff. It logs each attempt and updates retryCount.

A silent failure occurs when the app starts with no network, creates a task, and then goes offline. The task is saved locally, but no sync runs. The user expects the task to be uploaded.

To detect this, the app checks navigator.onLine and sync_engine.status on app start. If status is idle and online, it triggers sync_engine.start().

The sync engine also runs on app resume. On iOS, it uses UIApplication.willEnterForegroundNotification. On Android, Activity.onResume().

Data Persistence and Versioning

The app must survive crashes, updates, and storage limits.

On app startup, it validates the local database. If the version in settings differs from the expected version, it runs a migration.

Migrations are applied in a migrations table:

CREATE TABLE migrations (
    id INTEGER PRIMARY KEY,
    version INTEGER NOT NULL,
    applied_at DATETIME,
    script TEXT NOT NULL
);

The app runs migrations with version > lastAppliedVersion.

For large data sets, the app uses pagination and delta sync. It fetches only changes since the last sync, not full datasets.

When the database grows beyond 50MB, the app runs a cleanUp task: delete old sync_log entries, compress app_state.json, and archive logs/*.log.

Offline-First Workflows

The app supports workflows that depend on offline data.

The app supports offline-first navigation. Tabs and screens are pre-loaded. When a user navigates to a screen, it checks if the data is cached. If not, it shows a skeleton loader.

Final Configuration and Startup

The app starts with a bootstrap process that:

  1. Reads config.json.
  2. Initializes the database.
  3. Loads app_state.json from disk.
  4. Applies migrations.
  5. Starts the sync engine.
  6. Registers network listeners.

The app_state.json is updated on every state change. The app writes it on app:background, app:suspend, and app:shutdown.

The sync engine runs on background and networkAvailable events. It batches changes and sends them in groups of 50.

The app is resilient to:

It is now ready for production use.

This page was rewritten on 17 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.