Skip to main content
3Nsofts logo3Nsofts

Insights / iOS Architecture

Local-First iOS Architecture

Not offline-capable — offline-first. The design premise that changes your data model, conflict resolution strategy, and sync architecture from day one. With production patterns for SwiftData, Core Data, and NSPersistentCloudKitContainer.

By Ehsan Azish · 3NSOFTS · March 2026SwiftData · Core Data · CloudKit · iOS 17+

Offline-capable vs offline-first: a different premise

Most iOS apps that "support offline mode" are designed as offline-capable: the server is the source of truth, the local store is a cache, and the app degrades gracefully when the network is unavailable. This works with reliable connectivity. It fails when the user is on a plane, in a basement, or in any situation where the network is intermittently available for hours rather than seconds.

Offline-first inverts the premise: the local store is the source of truth. All reads and writes go to local persistence always — not as a fallback. Sync to the cloud is a background process, not a precondition for functionality. The user never sees a spinner for a basic read or write.

Offline-capable (avoid)

  • • Reads go to server first, cache on success
  • • Writes fail if offline — queued for retry
  • • Source of truth: server
  • • Conflict resolution: rare — server always wins
  • • Sync state: hidden from the user

Offline-first (recommended)

  • • Reads always go to local store — instant
  • • Writes go to local store first, sync queued
  • • Source of truth: local store
  • • Conflict resolution: designed explicitly
  • • Sync state: visible and honest

Data model design for sync

A data model designed for local-only use is often not sync-ready. There are three properties every syncable entity needs that are easy to overlook when designing for single-device use.

// ✅ Sync-ready SwiftData model
@Model
final class Entry {
    // 1. Stable UUID — not a database-generated Int ID
    // Must be stable across devices and after deletion/recreation
    var id: UUID = UUID()

    // 2. User-visible content
    var title: String = ""
    var body: String = ""

    // 3. Modification timestamp — used for conflict resolution
    // Update this on every write, not just on creation
    var modifiedAt: Date = Date.now

    // 4. Soft delete — don't physically delete records before sync
    // CloudKit cannot sync a deletion to a device that never received the record
    var isDeleted: Bool = false
    var deletedAt: Date? = nil

    // 5. Sync state — track per-record, not globally
    @Transient var syncStatus: SyncStatus = .unknown

    enum SyncStatus {
        case unknown, pending, synced, conflict
    }
}

// ❌ Not sync-ready
@Model
final class BadEntry {
    // Auto-incremented Int ID — not stable across devices
    // @Attribute(.primaryKey) var id: Int   <- don't do this
    var name: String = ""
    // No modification timestamp — conflict resolution is guesswork
    // No soft delete — deletions may be lost during sync gaps
}
  • Always use UUID as the primary key for syncable entities. CloudKit uses the record ID as the stable reference across devices.
  • Soft deletes prevent sync gaps where a deletion in CloudKit arrives before the original creation on a newly-restored device.
  • Always update modifiedAt on every write — this is the tie-breaker in last-write-wins conflict resolution.

NSPersistentCloudKitContainer: production setup

NSPersistentCloudKitContainer handles sync in the background with minimal code. The non-obvious parts are the merge policy, notification handling, and CloudKit account state management.

import CoreData
import CloudKit

class PersistenceController {
    static let shared = PersistenceController()

    let container: NSPersistentCloudKitContainer

    init() {
        container = NSPersistentCloudKitContainer(name: "MyApp")

        // Enable remote change notifications — needed to detect
        // CloudKit changes when app is in the background
        guard let description = container.persistentStoreDescriptions.first else {
            fatalError("No persistent store description found")
        }
        description.setOption(true as NSNumber,
            forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
        description.setOption(true as NSNumber,
            forKey: NSPersistentHistoryTrackingKey)

        container.loadPersistentStores { _, error in
            if let error { fatalError("CoreData load failed: \(error)") }
        }

        // NSMergeByPropertyObjectTrumpMergePolicy:
        // In-memory changes win over persistent store changes.
        // Correct for most local-first patterns.
        container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
        container.viewContext.automaticallyMergesChangesFromParent = true
    }
}

// Listen for CloudKit sync events
class SyncMonitor: ObservableObject {
    @Published var lastSyncDate: Date?
    @Published var syncFailed = false

    init() {
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(handleCloudKitEvent(_:)),
            name: NSPersistentCloudKitContainer.eventChangedNotification,
            object: nil
        )
    }

    @objc private func handleCloudKitEvent(_ notification: Notification) {
        guard let event = notification.userInfo?[
            NSPersistentCloudKitContainer.eventNotificationUserInfoKey
        ] as? NSPersistentCloudKitContainer.Event else { return }

        DispatchQueue.main.async {
            if event.type == .import && event.succeeded {
                self.lastSyncDate = Date()
                self.syncFailed = false
            } else if !event.succeeded {
                self.syncFailed = true
            }
        }
    }
}

Conflict resolution strategies

Most local-first apps can use last-write-wins. Some data types require something more sophisticated. Choose the strategy before writing the data model.

Last-write-wins (default)

Use whenNotes, settings, profiles — any data where the most recent edit is always correct
ImplementationNSMergeByPropertyObjectTrumpMergePolicy + modifiedAt timestamp. One device's write overwrites another's if it is newer.
Trade-offLoses data if two devices make different edits to the same field while offline. Acceptable for most single-user apps.

Append-only / event log

Use whenLedger data, activity logs, chat messages — any data where all writes are individually meaningful
ImplementationEach write creates a new record with a UUID and timestamp. Never update or delete records. Derive current state by reading all events in order.
Trade-offStorage grows unboundedly without compaction. Compaction requires coordination. Most appropriate for financial data.

CRDT (Conflict-free Replicated Data Type)

Use whenCollaborative editing, counters, sets — data with mathematically merge-able operations
ImplementationDesign the data type so any two diverged states can be merged deterministically. Counters, grow-only sets, and LWW-element-sets are common examples.
Trade-offComplex to implement correctly. Overkill for most iOS apps. Consider before Figma-style collaborative apps.

Sync state UI: be honest with users

Most apps hide sync state entirely, leaving users uncertain whether their data is persisted to iCloud. A small sync indicator — not intrusive, not alarming — builds trust.

struct SyncStatusView: View {
    @Environment(SyncMonitor.self) private var sync;

    var body: some View {
        HStack(spacing: 6) {
            if sync.syncFailed {
                Image(systemName: "exclamationmark.icloud")
                    .foregroundStyle(.orange)
                Text("Sync paused")
                    .font(.caption2)
                    .foregroundStyle(.secondary)
            } else if let date = sync.lastSyncDate {
                Image(systemName: "checkmark.icloud")
                    .foregroundStyle(.green)
                Text("Synced \(date.formatted(.relative(presentation: .numeric)))")
                    .font(.caption2)
                    .foregroundStyle(.secondary)
            } else {
                // Never synced — either offline or first launch
                Image(systemName: "icloud")
                    .foregroundStyle(.secondary)
            }
        }
    }
}

// Always handle CloudKit account unavailability gracefully
// The app MUST work without iCloud — don't make sync a hard dependency
struct RootView: View {
    @State private var cloudStatus: CKAccountStatus = .couldNotDetermine

    var body: some View {
        ContentView()
            .task {
                cloudStatus = try? await CKContainer.default().accountStatus() ?? .noAccount
            }
            .overlay(alignment: .top) {
                if cloudStatus == .noAccount {
                    Text("iCloud unavailable — data saved locally")
                        .font(.caption2)
                        .foregroundStyle(.secondary)
                        .padding(4)
                }
            }
    }
}

Frequently asked questions

What is the difference between offline-capable and offline-first?
Offline-capable: the server is the source of truth, the local cache is a degraded fallback. Offline-first: the local store is always the source of truth, sync is a background concern. The distinction changes the data model, conflict resolution strategy, and UX.
How does NSPersistentCloudKitContainer handle conflicts?
Last-write-wins by default, controlled by the merge policy. NSMergeByPropertyObjectTrumpMergePolicy is the most common correct choice — in-memory changes beat persistent store changes. For collaborative data where both writes matter, you need a custom conflict resolution strategy.
Does NSPersistentCloudKitContainer work in the simulator?
Only with a real iCloud account configured on the simulator. In practice, test CloudKit sync on real devices. The simulator's CloudKit container uses the development environment and is less reliable for sync testing.
SwiftData or Core Data for a new local-first app?
iOS 18+: SwiftData is stable enough. iOS 17 targets: Core Data is safer — SwiftData has known bugs with composite predicates and some relationship queries. Both use NSPersistentCloudKitContainer under the hood for CloudKit sync.

Authoritative References

Related