Offline-First iOS Architecture: Core Data + CloudKit Sync Without the Footguns
Offline-first is not a feature request — it is a design premise. This article covers the specific decisions that make Core Data + CloudKit sync work correctly: merge policies, container configuration, conflict resolution, and the failure modes that catch teams off guard.
The design premise
Offline-first is not a feature request. It is a design premise, and the entire data layer either reflects that or it doesn't.
Most iOS apps treat network connectivity as the default and offline as a degraded fallback. That inversion is the source of most sync bugs, most data loss incidents, and most issues that only surface in production when a user is on a subway or in a warehouse with no signal. Getting the architecture right from the start avoids retrofitting conflict resolution into a system that was never designed for it.
Offline-first means the app is fully functional with zero network connectivity. Not "mostly functional." Not "read-only when offline." Fully functional.
Writes go to the local NSPersistentContainer first. The app never waits for a network response before confirming an operation to the user. CloudKit sync happens in the background when connectivity is available — the user is never aware of the sync cycle unless something requires their attention.
Every architectural decision flows from that.
Container configuration
The obvious starting point is NSPersistentCloudKitContainer. It replaces NSPersistentContainer and adds CloudKit sync with minimal configuration changes. The problem: the default configuration is not production-ready.
NSPersistentCloudKitContainer uses a single persistent store by default. That store is mirrored to CloudKit's private database under the authenticated iCloud account. This works for single-user apps with simple data models. It breaks in two specific cases: shared data between users, and data that should never leave the device.
The correct configuration separates concerns across three stores:
- Private store — user-specific data, synced to CloudKit private database
- Shared store — data shared between users, synced to CloudKit shared database
- Local store — device-only data (credentials, UI state, cached computations) that must never sync
lazy var persistentContainer: NSPersistentCloudKitContainer = {
let container = NSPersistentCloudKitContainer(name: "DataModel")
guard let privateStoreDesc = container.persistentStoreDescriptions.first else {
fatalError("No persistent store descriptions found")
}
// Private CloudKit store
let privateStoreURL = AppGroup.containerURL.appendingPathComponent("private.sqlite")
privateStoreDesc.url = privateStoreURL
privateStoreDesc.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
containerIdentifier: "iCloud.com.example.app"
)
// Local store — never syncs
let localStoreURL = AppGroup.containerURL.appendingPathComponent("local.sqlite")
let localStoreDesc = NSPersistentStoreDescription(url: localStoreURL)
localStoreDesc.cloudKitContainerOptions = nil
container.persistentStoreDescriptions.append(localStoreDesc)
container.loadPersistentStores { _, error in
if let error { fatalError("Store load failed: \(error)") }
}
container.viewContext.automaticallyMergesChangesFromParent = true
return container
}()
Separating the local store is not optional if the app handles any sensitive data. CloudKit sync is transparent by design — if you don't explicitly opt out, data migrates to iCloud.
Merge policies
The default merge policy for NSManagedObjectContext is NSErrorMergePolicy. It throws an error on any conflict and expects the caller to resolve it manually. That is the wrong default for an offline-first system.
An offline-first app will produce conflicts. A user edits a record on their iPhone while offline. The same record was edited on their iPad while that device was also offline. When both devices reconnect, CloudKit delivers the remote changes and the local context now has a conflict.
NSErrorMergePolicy surfaces this as an error and discards the write. That is data loss.
The correct policies for most offline-first apps:
NSMergeByPropertyObjectTrumpMergePolicy— in-memory changes win over persistent store changes. Use this on the view context, where the user's most recent action should always persist.NSMergeByPropertyStoreTrumpMergePolicy— persistent store changes win over in-memory changes. Use this on background contexts performing sync operations, where incoming CloudKit data should not be overwritten by stale in-memory state.
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
container.viewContext.automaticallyMergesChangesFromParent = true
let syncContext = container.newBackgroundContext()
syncContext.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy
automaticallyMergesChangesFromParent = true on the view context means CloudKit-delivered changes propagate to the UI automatically. Without this, the UI shows stale data until the next explicit fetch.
Conflict resolution at the data model level
Merge policies handle context-level conflicts. They do not handle semantic conflicts — cases where two valid writes produce logically inconsistent state.
Consider an inventory app where two users simultaneously decrement a stock count from 3 to 2. Both writes are locally valid. After sync, the count is 2, but the correct value is 1. Neither NSMergeByPropertyObjectTrumpMergePolicy nor NSMergeByPropertyStoreTrumpMergePolicy catches this — they resolve the conflict by choosing a winner, not by understanding the intent.
The fix is a last-write-wins timestamp with a vector clock fallback for fields where order matters. For fields where the operation is commutative (append to a list, increment a counter), the data model needs to store the delta, not the result.
@NSManaged var stockDelta: Int64 // store -1, not the resulting count
@NSManaged var lastModified: Date
@NSManaged var deviceID: String // for deterministic tiebreaking
The app reconstructs the current stock count by summing all deltas. CloudKit delivers all delta records. No conflict exists because no two records represent the same state — they represent operations.
This pattern is more work upfront. It eliminates an entire class of production data corruption.
Handling NSPersistentCloudKitContainer event notifications
CloudKit sync is asynchronous and opaque by default. The system does not expose a "sync complete" callback. This surprises most teams the first time they build against it.
NSPersistentCloudKitContainer posts NSPersistentCloudKitContainer.Event notifications via NotificationCenter. These events carry type, start date, end date, and an error if the operation failed. Observing them is the only way to surface sync state to the user.
NotificationCenter.default.publisher(
for: NSPersistentCloudKitContainer.eventChangedNotification
)
.compactMap {
$0.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey]
as? NSPersistentCloudKitContainer.Event
}
.filter { $0.type == .export || $0.type == .import }
.sink { event in
if let error = event.error {
// Log and surface sync failure to user if appropriate
logger.error("CloudKit sync failed: \(error)")
}
}
.store(in: &cancellables)
The event types are .setup, .import, and .export. A failed .import means incoming changes from CloudKit did not apply. A failed .export means local changes did not reach CloudKit. Both require different recovery paths.
Teams that skip this notification observer end up with silent sync failures and no diagnostic path.
Background sync and app lifecycle
CloudKit delivers remote changes via silent push notifications. The system wakes the app in the background, NSPersistentCloudKitContainer processes the incoming records, and the view context updates via automaticallyMergesChangesFromParent.
This requires Background App Refresh to be enabled and the remote-notification background mode declared in the app's entitlements. Without these, CloudKit sync only runs when the app is in the foreground.
The footgun here is BGProcessingTask. Teams sometimes schedule explicit background sync tasks thinking they control the sync cycle. They don't. NSPersistentCloudKitContainer manages its own sync schedule. Adding a BGProcessingTask that triggers a Core Data save creates a second sync path that competes with the container's internal scheduler — producing duplicate writes and unpredictable merge behavior.
Schema versioning and migration
CloudKit imposes constraints on schema changes that Core Data's local migration path does not:
- You cannot delete attributes from a record type once records exist in CloudKit
- You cannot change the type of an existing attribute
- Adding optional attributes is the only safe schema change after records have synced
The practical implication: design the schema conservatively from the start. Fields you think are temporary stay in the CloudKit schema permanently once data is live. Mark every attribute as optional — CloudKit's private database requires it for records that may be created before a schema change is received.
Testing offline behavior
Testing offline-first apps requires more than unit tests:
- Use
XCTestExpectationwith network interruption to verify writes persist before connectivity returns - Test conflict resolution by writing to two separate in-memory contexts and merging
- Use Charles Proxy or Network Link Conditioner to simulate flaky connectivity during sync
- Test cold launch offline to ensure the app bootstraps from local store without any network dependency
When this architecture applies
Core Data + CloudKit sync is the right architecture when:
- Data must be consistent across multiple devices owned by the same user
- The app must function in environments with extended connectivity gaps
- User data privacy requires that no third-party server holds a copy
If the app has no multi-device or multi-user requirement and all data is single-device, a local NSPersistentContainer without CloudKit is simpler and avoids the schema versioning constraints.
The footguns, summarised
| Footgun | What it causes | Fix |
|---|---|---|
| Default NSErrorMergePolicy | Data loss on conflicts | Use property-trump policies |
| Single store for all data | Sensitive data syncs to iCloud | Separate local + private stores |
| No event notifications | Silent sync failures | Observe eventChangedNotification |
| BGProcessingTask for sync | Duplicate writes, unpredictable merges | Let the container manage sync |
| Required attributes in CloudKit | Schema migration failures | Make all synced attributes optional |
| Semantic conflicts ignored | Data corruption at scale | Model operations, not state |