Skip to main content
3Nsofts logo3Nsofts
iOS Architecture

Local-First iOS Architecture: Building Apps That Work Offline

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 2026·8 min read

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 
 
 - ▸ 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.

 

```swift
import CoreData

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 
 }

 // 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 

 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.

| Strategy | When | Impl | Tradeoff | | --- | --- | --- | --- | | Last-write-wins (default) | Notes, settings, profiles — any data where the most recent edit is always correct | NSMergeByPropertyObjectTrumpMergePolicy + modifiedAt timestamp. One device's write overwrites another's if it is newer. | Loses data if two devices make different edits to the same field while offline. Acceptable for most single-user apps. | | Append-only / event log | Ledger data, activity logs, chat messages — any data where all writes are individually meaningful | Each write creates a new record with a UUID and timestamp. Never update or delete records. Derive current state by reading all events in order. | Storage grows unboundedly without compaction. Compaction requires coordination. Most appropriate for financial data. | | CRDT (Conflict-free Replicated Data Type) | Collaborative editing, counters, sets — data with mathematically merge-able operations | Design the data type so any two diverged states can be merged deterministically. Counters, grow-only sets, and LWW-element-sets are common examples. | Complex 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

Authoritative References