| name | cloudkit-sync |
| description | Expert guidance for CloudKit sync on Apple platforms. Use when developers mention: (1) iCloud sync or cross-device sync of app data, (2) SwiftData + CloudKit or NSPersistentCloudKitContainer, (3) CKSyncEngine or custom CloudKit record sync, (4) CloudKit sharing / collaboration (CKShare), (5) CloudKit entitlements, containers, or schema deployment, (6) conflict resolution or offline-first with iCloud. Not for third-party sync backends (Firebase, Supabase) and not for app-server REST sync (see docs/api/sync.md). |
| license | MIT |
| metadata | {"author":"ulk","version":"1.0","source":"ulk (interne) — extrait des sections natives d'isaac (27), agents/mobile/27-isaac.md","source-version":"ios-26","maintainer":"izo"} |
CloudKit Sync
Scope: syncing app data across a user's Apple devices via iCloud — SwiftData automatic sync (iOS 17+), NSPersistentCloudKitContainer (Core Data legacy), CKSyncEngine (custom stores, iOS 17+), sharing/collaboration, schema lifecycle, entitlements.
Out of scope: CloudKit Web Services, server-to-server tokens, CloudKit JS; syncing with the app's own backend (that is REST/offline-sync territory — docs/api/sync.md).
Choosing the sync stack
| Situation | Use | Notes |
|---|
| SwiftData models, standard sync | SwiftData + CloudKit (ModelConfiguration(cloudKitDatabase:)) | Least code, most constraints |
| Existing Core Data stack | NSPersistentCloudKitContainer | Same constraints as SwiftData |
| Custom persistence (files, SQLite, GRDB) | CKSyncEngine | You own the local store + conflict merge |
| User-to-user collaboration | CloudKit shared database + CKShare | Works atop any of the above |
| Cross-platform account (Android/web planned) | Don't use CloudKit as primary store | Own backend via Happy docs/api/ |
Decision rule: CloudKit = same-user, multi-device, Apple-only. If the product roadmap includes non-Apple clients or server-side features (search, admin, analytics on user data), CloudKit is a cache/transport, not the source of truth.
Core Rules (non-negotiable)
- Model for CloudKit from day one (mirrored stores). SwiftData/Core Data + CloudKit requires: every property optional or with a default value, no
@Attribute(.unique) / no unique constraints, relationships optional, no ordered relationships. Retrofitting these onto a shipped schema is a migration project.
- The schema must be deployed to Production before release. Development schema auto-creates record types when you run debug builds; the App Store build hits the Production environment where nothing exists until you press Deploy Schema Changes in CloudKit Console. Forgetting this is the #1 "sync works in TestFlight, dies in prod" bug. Production schema fields can be added but never removed/renamed — additive evolution only.
- Entitlements checklist: iCloud capability + CloudKit + a container ID (
iCloud.com.example.app), plus Background Modes → Remote notifications (silent pushes drive sync). Missing background push = "sync only when I open the app".
- Sync is eventually consistent — design the UI for it. No spinners blocking on "sync done"; render local data immediately, merge remote changes as they land (
@Query/NSFetchedResultsController update automatically for the container stacks).
- Handle account state.
CKContainer.accountStatus(): no iCloud account, restricted, or iCloud Drive off → app must still work locally and say why sync is off. Never crash or silently drop writes.
- Conflicts: container stacks use last-writer-wins per field — acceptable for most apps, but say so consciously. With
CKSyncEngine, you receive server records on conflict and must merge (three-way merge with the last-known-server copy is the robust default).
- Batch limits are real: ~400 records / ~2 MB per operation are the practical ceilings (
CKError.limitExceeded → split). Assets (CKAsset) for anything > ~100 KB of binary data, never bytes-in-record fields.
- Rate-limit awareness: on
CKError.requestRateLimited / .zoneBusy, honor retryAfterSeconds from the error's userInfo. Never hot-loop retries.
- Zones: custom
CKRecordZone per logical dataset (required for sharing and for efficient change-token fetches). The default zone can't be shared and can't do delta fetches.
- Privacy manifest & UX: CloudKit data is the user's, in their iCloud quota. Provide a way to delete their data; deleting the app does not delete the CloudKit records.
Canonical Patterns
SwiftData + CloudKit (iOS 17+)
@Model
final class Note {
var title: String = ""
var body: String = ""
var createdAt: Date = Date.now
var tags: [Tag]? = nil
init() {}
}
let config = ModelConfiguration("Main", cloudKitDatabase: .private("iCloud.com.example.app"))
let container = try ModelContainer(for: Note.self, Tag.self, configurations: config)
Verify in CloudKit Console (Development) that record types appeared, then deploy to Production before submission.
CKSyncEngine skeleton (custom store)
actor SyncService {
private var engine: CKSyncEngine!
func start(stateSerialization: CKSyncEngine.State.Serialization?) {
var config = CKSyncEngine.Configuration(
database: CKContainer(identifier: "iCloud.com.example.app").privateCloudDatabase,
stateSerialization: stateSerialization,
delegate: self
)
engine = CKSyncEngine(config)
}
}
extension SyncService: CKSyncEngine.Delegate {
func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async {
switch event {
case .stateUpdate(let update): await persist(update.stateSerialization)
case .fetchedRecordZoneChanges(let changes): await merge(changes)
case .sentRecordZoneChanges(let sent): await reconcile(sent)
default: break
}
}
func nextRecordZoneChangeBatch(_ context: CKSyncEngine.SendContext,
syncEngine: CKSyncEngine) async -> CKSyncEngine.RecordZoneChangeBatch? {
await pendingLocalChangesBatch()
}
}
Non-negotiables with CKSyncEngine: persist stateSerialization on every .stateUpdate; keep a last-known-server copy of each record for three-way merge; enqueue local edits as pending changes, let the engine schedule.
Sharing (collaboration)
CKShare on a custom zone (share whole zone) or a root record hierarchy; present with UICloudSharingController / SwiftUI CloudSharingView-style wrappers; accepted shares land in the shared database — fetch with the same engine/container pointed at .sharedCloudDatabase.
Common AI Mistakes
| ❌ Generated pattern | Why it fails | ✅ Correct |
|---|
@Attribute(.unique) on a synced model | Container init throws / silent no-sync | Enforce uniqueness in app logic |
| Non-optional relationship in synced model | Incompatible with mirroring | Optional relationship + default handling |
| "Works on my device" → ship | Production schema never deployed | Deploy schema in CloudKit Console pre-release |
| Sync without remote-notification background mode | Changes appear only on foreground fetch | Enable Background Modes → Remote notifications |
| Blocking launch on full sync | Eventual consistency ignored | Local-first render, merge as events arrive |
Retrying .requestRateLimited in a loop | Throttled harder | Honor retryAfterSeconds |
Storing images as Data fields in records | Blows record size limits | CKAsset |
| Default zone for shareable data | Default zone can't be shared | Custom CKRecordZone |
| Treating CloudKit as the backend for a cross-platform app | Locks data in Apple-only silo | Own API (Happy docs/api/), CloudKit for device sync only |
Checklist before shipping