Skip to content
iOS

Skill: Offline Sync

Overview

Offline sync makes the local store the source of truth so the app works without a network, then reconciles with the server when connectivity returns. It combines local persistence, a change/outbox queue for pending mutations, conflict resolution, and optimistic UI updates with rollback. This is one of the hardest mobile problems — design the failure and conflict cases explicitly, not just the happy path.

Use Cases

  • Notes/tasks/messaging apps that must work offline.
  • Field apps with intermittent connectivity.
  • Any app where waiting on the network for every action is unacceptable UX.

Best Practices

  • Local store is the source of truth; the UI reads from it, not directly from the network.
  • Queue mutations in an outbox with status (pending/syncing/failed) and retry with backoff.
  • Use optimistic updates with a clear rollback path on server rejection.
  • Define a conflict strategy explicitly: last-write-wins (with server timestamps), field-level merge, or CRDTs for collaborative data.
  • Use stable client-generated ids (UUIDs) so offline-created items reconcile cleanly.
  • Track a sync cursor / updatedAt to fetch only deltas.

Anti-Patterns

  • ❌ Treating the network as the source of truth and blocking the UI offline.
  • ❌ No conflict strategy → silent data loss.
  • ❌ Optimistic updates with no rollback on failure.
  • ❌ Server-assigned ids only, breaking offline creation references.
  • ❌ Full re-fetch every sync instead of deltas.

Checklist

  • [ ] Local store is the read source of truth.
  • [ ] Outbox queue with status + backoff retry.
  • [ ] Optimistic updates have a rollback path.
  • [ ] Explicit, documented conflict-resolution strategy.
  • [ ] Stable client ids; delta sync via cursor/updatedAt.

Swift Examples

swift
enum SyncStatus: String { case pending, syncing, synced, failed }

struct PendingMutation: Identifiable, Codable {
    let id: UUID                  // stable client id
    let type: MutationType
    let payload: Data
    var status: SyncStatus
    var attempts: Int
}

actor SyncEngine {
    private let outbox: OutboxStore
    private let api: SyncAPI

    func enqueue(_ mutation: PendingMutation) async { await outbox.add(mutation) }

    func sync() async {
        for var mutation in await outbox.pending() {
            mutation.status = .syncing; await outbox.update(mutation)
            do {
                try await api.apply(mutation)
                await outbox.remove(mutation.id)
            } catch let error as ConflictError {
                await resolve(error, for: mutation)        // explicit conflict handling
            } catch {
                mutation.status = .failed; mutation.attempts += 1
                await outbox.update(mutation)              // retried later with backoff
            }
        }
    }
}

Common Interview Questions

  • Why make the local store the source of truth?
  • How do you handle conflicts (LWW vs merge vs CRDT)?
  • Why use client-generated ids for offline creation?
  • How do optimistic updates and rollback work?
  • How do you sync only deltas?

AI Implementation Notes