Offline First Sync

Production engineering guide for offline first sync covering patterns, implementation strategies, and operational best practices.

Offline First Sync is a strategy for mobile applications that ensures data is available and consistent even when the device is disconnected from the network. It matters when users work in environments with unreliable or intermittent connectivity—field service, remote healthcare, logistics, or commuter travel—and expect to create, edit, and view data seamlessly across sessions. The core promise is that users can interact with the app as if online at all times, and changes are synchronized in the background when connectivity resumes.

Core Principles of Offline First Sync

The offline-first sync pattern rests on three pillars: local persistence, change tracking, and conflict resolution.

Local persistence means every data operation writes to a local database (typically SQLite or LevelDB) before attempting network reachability. Changes are queued and persisted even when the device is off or app is backgrounded. The system must survive app restarts, device reboots, and network flaps without losing state.

Change tracking records every modification—inserts, updates, deletions—as a sequence of operations, each associated with a timestamp and a unique ID. These operations are stored in a *changes log* that is compact, append-only, and indexable. The log is the source of truth for what needs syncing.

Conflict resolution handles the case when two or more changes are applied to the same data item while offline. The system detects and resolves conflicts based on timestamps, version numbers, or custom logic, and provides a way to surface merge conflicts to the user.

Managing the Sync Lifecycle

Sync begins when connectivity is detected. The process is triggered by a combination of system events: network availability, app foregrounding, or a periodic background task.

Starting Sync from a Background Task

A mobile app uses a system-level background task to initiate sync when the device becomes online.


let task = BGTaskScheduler.shared.getBackgroundTask(withIdentifier: "com.example.app.sync") { task in
    let syncTask = SyncTask()
    syncTask.start { success in
        task.setTaskCompleted(success: success)
    }
}

BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.example.app.sync", using: .background) { task in
    task.setTaskCompleted(success: true)
}

When the sync task completes, the system updates the app’s sync status, triggers a notification, and updates a sync progress indicator.

Syncing Data via HTTP

The sync process sends changes to a REST API endpoint. The client sends a batch of operations in a JSON payload.

POST /api/sync HTTP/1.1
Host: api.example.com
Authorization: Bearer abc123
Content-Type: application/json

{
  "client_id": "mobile-iphone-7",
  "last_synced_at": "2024-04-15T12:34:56Z",
  "changes": [
    {
      "type": "create",
      "table": "tasks",
      "id": "task-42",
      "data": {
        "title": "Review proposal",
        "due_date": "2024-04-20",
        "status": "pending"
      }
    },
    {
      "type": "update",
      "table": "tasks",
      "id": "task-43",
      "data": {
        "status": "in-progress"
      }
    },
    {
      "type": "delete",
      "table": "tasks",
      "id": "task-44"
    }
  ]
}

The server responds with the result of applying the changes, including any new or updated records, and a new last_synced_at timestamp.

HTTP/1.1 200 OK
Content-Type: application/json

{
  "synced_at": "2024-04-15T12:35:01Z",
  "applied": [
    {
      "table": "tasks",
      "id": "task-42",
      "server_id": "srv-task-1001",
      "updated_at": "2024-04-15T12:35:01Z"
    },
    {
      "table": "tasks",
      "id": "task-43",
      "server_id": "srv-task-1002",
      "updated_at": "2024-04-15T12:35:01Z"
    }
  ],
  "conflicts": [
    {
      "table": "tasks",
      "id": "task-45",
      "local": {
        "status": "completed",
        "updated_at": "2024-04-15T12:30:00Z"
      },
      "server": {
        "status": "in-progress",
        "updated_at": "2024-04-15T12:33:00Z"
      }
    }
  ]
}

The client then updates its local database with the new server IDs and timestamps, and records the new last_synced_at value.

Conflict Resolution Strategies

Conflicts arise when the same record is modified both locally and on the server between syncs.

Timestamp-Based Resolution

The most common strategy compares timestamps. The record with the later updated_at timestamp wins.

{
  "table": "tasks",
  "id": "task-45",
  "local": {
    "status": "completed",
    "updated_at": "2024-04-15T12:30:00Z"
  },
  "server": {
    "status": "in-progress",
    "updated_at": "2024-04-15T12:33:00Z"
  }
}

The server’s version is applied, and the client’s version is stored as a *conflict* in the database.

Manual Merge Resolution

When the client detects a conflict, it marks the record as conflicted and presents a merge UI.

-- In the local SQLite database
CREATE TABLE conflicts (
    id TEXT PRIMARY KEY,
    table_name TEXT NOT NULL,
    record_id TEXT NOT NULL,
    local_data JSON NOT NULL,
    server_data JSON NOT NULL,
    resolved BOOLEAN DEFAULT FALSE,
    resolved_at DATETIME,
    resolved_by TEXT
);

The user selects which version to keep, merges fields, or creates a new version. The system records the merge choice and applies the resolved data to the local database.

Automatic Merge with Versioning

For more complex data, the system uses a version number (or version_id) field.

{
  "id": "task-45",
  "title": "Submit report",
  "status": "in-progress",
  "version_id": 3,
  "updated_at": "2024-04-15T12:30:00Z"
}

When syncing, the client includes the version_id with each change. The server compares the incoming version_id with the current version. If they differ, a conflict is detected. The server applies the new version and returns the updated version_id.

Optimizing for Bandwidth and Latency

Mobile networks are expensive and slow. The sync system must minimize data transfer.

Batched and Compressed Sync

Changes are sent in batches of up to 100 operations, and the entire payload is compressed with gzip.

POST /api/sync HTTP/1.1
Host: api.example.com
Authorization: Bearer abc123
Content-Encoding: gzip
Content-Type: application/json

{ "changes": [ ... ] }

The server can process the batch and return a compact response.

Delta Syncs

Instead of sending full records, the client sends only the changed fields.

{
  "type": "update",
  "table": "tasks",
  "id": "task-45",
  "changes": {
    "status": "completed",
    "notes": "Added final review section"
  }
}

The server applies only the delta to the existing record.

Sync State and Progress Tracking

The client maintains a sync state object to track progress and display status.

{
  "last_synced_at": "2024-04-15T12:35:01Z",
  "syncing": true,
  "pending_changes": 17,
  "conflicts": 3,
  "last_error": {
    "code": "network_timeout",
    "message": "Request timed out after 30s",
    "timestamp": "2024-04-15T12:34:45Z"
  }
}

This state is used to update UI elements: a sync indicator in the app bar, a progress bar, and a list of pending tasks.

Failure Modes and Common Pitfalls

Silent Sync Failures

Sync may appear successful but fail silently. The client sends changes and receives a 200 OK, but the server does not persist the data.

Common causes:

The client should verify sync success by checking that the last_synced_at value is persisted locally after a sync.

Duplicate Syncs

When the device reconnects quickly, the sync process may be triggered multiple times, leading to duplicate changes.

The client must deduplicate sync requests by:

{
  "client_id": "mobile-iphone-7",
  "sync_id": "sync-5a3b2c1d",
  "changes": [...]
}

The server maintains a synced_sync_ids set and skips processing for already-synced sync_ids.

Conflict Resolution Without Conflict Detection

The client sends changes but does not check for conflicts. As a result, it overwrites server data without resolving conflicts.

The fix: after applying changes, the client queries the server for new conflicts and updates the local database accordingly.

Partial Syncs and Incomplete State

A sync may complete partially: some changes are applied, but others are lost due to a network drop.

The client should:

Sync Loop Detection

When a sync loop is triggered in a tight feedback loop—e.g., sync starts, then immediately triggers another sync—the system can enter a loop where each sync triggers the next.

To prevent this:

Configuration and Tuning

Sync behavior is tuned via a configuration object stored in the app’s settings.

{
  "sync": {
    "interval": "300s",
    "batch_size": 50,
    "max_concurrent_syncs": 3,
    "min_network_strength": "good",
    "retry_on_failure": true,
    "retry_delay": "30s",
    "sync_on_background": true,
    "sync_on_foreground": true,
    "sync_on_wifi": true,
    "sync_on_cellular": true,
    "sync_on_battery": true,
    "sync_on_low_battery": false,
    "sync_on_charging": true,
    "conflict_resolution": "timestamp",
    "merge_strategy": "server_wins",
    "auto_merge_threshold": 5,
    "enable_deltas": true,
    "compress_payloads": true
  }
}

The system applies these settings at runtime, adjusting behavior based on device state and network conditions.

Sync Debugging and Monitoring

The client logs sync operations to a structured log.

{
  "timestamp": "2024-04-15T12:34:45Z",
  "level": "info",
  "event": "sync_started",
  "client_id": "mobile-iphone-7",
  "sync_id": "sync-5a3b2c1d",
  "changes_count": 12,
  "network_type": "wifi"
}

Error logs include:

Each error includes the full request and response payloads, and a stack trace if the error occurs in code.

Summary

Offline First Sync is not a single feature. It is an integrated system of data persistence, change tracking, conflict resolution, and network-aware operation. It succeeds when users can create, edit, and view data without thinking about connectivity. It fails when changes are lost, conflicts are ignored, or syncs complete without being noticed. The pattern is defined by its data flow, error handling, and state management—not by any vendor’s name or UI.

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.