cloudkit-sync

Implement, review, or improve CloudKit and iCloud sync in iOS/macOS apps. Use when working with CKContainer, CKRecord, CKQuery, CKSubscription, CKSyncEngine,…

INSTALLATION
npx skills add https://github.com/dpearson2699/swift-ios-skills --skill cloudkit-sync
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

CloudKit and iCloud Sync

Sync data across devices using CloudKit, iCloud key-value storage, and iCloud

Drive. Covers container setup, record CRUD, queries, subscriptions, CKSyncEngine,

SwiftData integration, conflict resolution, and error handling. Targets iOS 26+

with Swift 6.2; older availability noted where relevant.

Contents

  • [Container and Database Setup](#container-and-database-setup)
  • [CKRecord CRUD](#ckrecord-crud)
  • [CKQuery](#ckquery)
  • [CKSubscription](#cksubscription)
  • [CKSyncEngine (iOS 17+)](#cksyncengine-ios-17)
  • [SwiftData + CloudKit](#swiftdata--cloudkit)
  • [NSUbiquitousKeyValueStore](#nsubiquitouskeyvaluestore)
  • [iCloud Drive File Sync](#icloud-drive-file-sync)
  • [Account Status and Error Handling](#account-status-and-error-handling)
  • [Conflict Resolution](#conflict-resolution)
  • [Common Mistakes](#common-mistakes)
  • [Review Checklist](#review-checklist)
  • [References](#references)

Container and Database Setup

Enable iCloud + CloudKit in Signing & Capabilities. A container provides

three databases:

Database

Scope

Requires iCloud

Storage Quota

Public

All users

Read: No, Write: Yes

App quota

Private

Current user

Yes

User quota

Shared

Shared records

Yes

Owner quota

import CloudKit

let container = CKContainer.default()

// Or named: CKContainer(identifier: "iCloud.com.example.app")

let publicDB  = container.publicCloudDatabase

let privateDB = container.privateCloudDatabase

let sharedDB  = container.sharedCloudDatabase

CKRecord CRUD

Records are key-value pairs. Max 1 MB per record (excluding CKAsset data).

// CREATE

let record = CKRecord(recordType: "Note")

record["title"] = "Meeting Notes" as CKRecordValue

record["body"] = "Discussed Q3 roadmap" as CKRecordValue

record["createdAt"] = Date() as CKRecordValue

record["tags"] = ["work", "planning"] as CKRecordValue

let saved = try await privateDB.save(record)

// FETCH by ID

let recordID = CKRecord.ID(recordName: "unique-id-123")

let fetched = try await privateDB.record(for: recordID)

// UPDATE -- fetch first, modify, then save

fetched["title"] = "Updated Title" as CKRecordValue

let updated = try await privateDB.save(fetched)

// DELETE

try await privateDB.deleteRecord(withID: recordID)

Custom Record Zones (Private/Shared Only)

Custom zones support atomic commits, change tracking, and sharing.

let zoneID = CKRecordZone.ID(zoneName: "NotesZone")

let zone = CKRecordZone(zoneID: zoneID)

try await privateDB.save(zone)

let recordID = CKRecord.ID(recordName: UUID().uuidString, zoneID: zoneID)

let record = CKRecord(recordType: "Note", recordID: recordID)

CKQuery

Query records with NSPredicate. Supported: ==, !=, <, >, <=, >=,

BEGINSWITH, CONTAINS, IN, AND, NOT, BETWEEN,

distanceToLocation:fromLocation:.

let predicate = NSPredicate(format: "title BEGINSWITH %@", "Meeting")

let query = CKQuery(recordType: "Note", predicate: predicate)

query.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]

let (results, _) = try await privateDB.records(matching: query)

for (_, result) in results {

    let record = try result.get()

    print(record["title"] as? String ?? "")

}

// Fetch all records of a type

let allQuery = CKQuery(recordType: "Note", predicate: NSPredicate(value: true))

// Full-text search across string fields

let searchQuery = CKQuery(

    recordType: "Note",

    predicate: NSPredicate(format: "self CONTAINS %@", "roadmap")

)

// Compound predicate

let compound = NSCompoundPredicate(andPredicateWithSubpredicates: [

    NSPredicate(format: "createdAt > %@", cutoffDate as NSDate),

    NSPredicate(format: "tags CONTAINS %@", "work")

])

CKSubscription

Subscriptions trigger push notifications when records change server-side.

CloudKit auto-enables APNs -- no explicit push entitlement needed.

// Query subscription -- fires when matching records change

let subscription = CKQuerySubscription(

    recordType: "Note",

    predicate: NSPredicate(format: "tags CONTAINS %@", "urgent"),

    subscriptionID: "urgent-notes",

    options: [.firesOnRecordCreation, .firesOnRecordUpdate]

)

let notifInfo = CKSubscription.NotificationInfo()

notifInfo.shouldSendContentAvailable = true  // silent push

subscription.notificationInfo = notifInfo

try await privateDB.save(subscription)

// Database subscription -- fires on any database change

let dbSub = CKDatabaseSubscription(subscriptionID: "private-db-changes")

dbSub.notificationInfo = notifInfo

try await privateDB.save(dbSub)

// Record zone subscription -- fires on changes within a zone

let zoneSub = CKRecordZoneSubscription(

    zoneID: CKRecordZone.ID(zoneName: "NotesZone"),

    subscriptionID: "notes-zone-changes"

)

zoneSub.notificationInfo = notifInfo

try await privateDB.save(zoneSub)

Handle in AppDelegate:

func application(

    _ application: UIApplication,

    didReceiveRemoteNotification userInfo: [AnyHashable: Any]

) async -> UIBackgroundFetchResult {

    let notification = CKNotification(fromRemoteNotificationDictionary: userInfo)

    guard notification?.subscriptionID == "private-db-changes" else { return .noData }

    // Fetch changes using CKSyncEngine or CKFetchRecordZoneChangesOperation

    return .newData

}

CKSyncEngine (iOS 17+)

CKSyncEngine is the recommended sync approach. It handles scheduling,

transient error retries, change tokens, and push notifications automatically.

Works with private and shared databases only.

import CloudKit

final class SyncManager: CKSyncEngineDelegate {

    let syncEngine: CKSyncEngine

    init(container: CKContainer = .default()) {

        let config = CKSyncEngine.Configuration(

            database: container.privateCloudDatabase,

            stateSerialization: Self.loadState(),

            delegate: self

        )

        self.syncEngine = CKSyncEngine(config)

    }

    func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) {

        switch event {

        case .stateUpdate(let update):

            Self.saveState(update.stateSerialization)

        case .accountChange(let change):

            handleAccountChange(change)

        case .fetchedRecordZoneChanges(let changes):

            for mod in changes.modifications { processRemoteRecord(mod.record) }

            for del in changes.deletions { processRemoteDeletion(del.recordID) }

        case .sentRecordZoneChanges(let sent):

            for saved in sent.savedRecords { markSynced(saved) }

            for fail in sent.failedRecordSaves { handleSaveFailure(fail) }

        default: break

        }

    }

    func nextRecordZoneChangeBatch(

        _ context: CKSyncEngine.SendChangesContext,

        syncEngine: CKSyncEngine

    ) -> CKSyncEngine.RecordZoneChangeBatch? {

        let pending = syncEngine.state.pendingRecordZoneChanges

        return CKSyncEngine.RecordZoneChangeBatch(

            pendingChanges: Array(pending)

        ) { recordID in self.recordToSend(for: recordID) }

    }

}

// Schedule changes

let zoneID = CKRecordZone.ID(zoneName: "NotesZone")

let recordID = CKRecord.ID(recordName: noteID, zoneID: zoneID)

syncEngine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])

// Trigger immediate sync (pull-to-refresh)

try await syncEngine.fetchChanges()

try await syncEngine.sendChanges()

Key point: persist stateSerialization across launches; the engine needs it

to resume from the correct change token.

SwiftData + CloudKit

ModelConfiguration supports CloudKit sync. CloudKit models must use optional

properties and avoid unique constraints.

import SwiftData

@Model

class Note {

    var title: String

    var body: String?

    var createdAt: Date?

    @Attribute(.externalStorage) var imageData: Data?

    init(title: String, body: String? = nil) {

        self.title = title

        self.body = body

        self.createdAt = Date()

    }

}

let config = ModelConfiguration(

    "Notes",

    cloudKitDatabase: .private("iCloud.com.example.app")

)

let container = try ModelContainer(for: Note.self, configurations: config)

CloudKit model rules: use optionals for all non-String properties; avoid

#Unique; keep models flat; use @Attribute(.externalStorage) for large data;

avoid complex relationship graphs.

NSUbiquitousKeyValueStore

Simple key-value sync. Max 1024 keys, 1 MB total, 1 MB per value. Stores

locally when iCloud is unavailable.

let kvStore = NSUbiquitousKeyValueStore.default

// Write

kvStore.set("dark", forKey: "theme")

kvStore.set(14.0, forKey: "fontSize")

kvStore.set(true, forKey: "notificationsEnabled")

kvStore.synchronize()

// Read

let theme = kvStore.string(forKey: "theme") ?? "system"

// Observe external changes

NotificationCenter.default.addObserver(

    forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,

    object: kvStore, queue: .main

) { notification in

    guard let userInfo = notification.userInfo,

          let reason = userInfo[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int,

          let keys = userInfo[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String]

    else { return }

    switch reason {

    case NSUbiquitousKeyValueStoreServerChange:

        for key in keys { applyRemoteChange(key: key) }

    case NSUbiquitousKeyValueStoreInitialSyncChange:

        reloadAllSettings()

    case NSUbiquitousKeyValueStoreQuotaViolationChange:

        handleQuotaExceeded()

    default: break

    }

}

iCloud Drive File Sync

Use FileManager ubiquity APIs for document-level sync.

guard let ubiquityURL = FileManager.default.url(

    forUbiquityContainerIdentifier: "iCloud.com.example.app"

) else { return }  // iCloud not available

let docsURL = ubiquityURL.appendingPathComponent("Documents")

let cloudURL = docsURL.appendingPathComponent("report.pdf")

try FileManager.default.setUbiquitous(true, itemAt: localURL, destinationURL: cloudURL)

// Monitor iCloud files

let query = NSMetadataQuery()

query.predicate = NSPredicate(format: "%K LIKE '*.pdf'", NSMetadataItemFSNameKey)

query.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope]

NotificationCenter.default.addObserver(

    forName: .NSMetadataQueryDidFinishGathering, object: query, queue: .main

) { _ in

    query.disableUpdates()

    for item in query.results as? [NSMetadataItem] ?? [] {

        let name = item.value(forAttribute: NSMetadataItemFSNameKey) as? String

        let status = item.value(

            forAttribute: NSMetadataUbiquitousItemDownloadingStatusKey) as? String

    }

    query.enableUpdates()

}

query.start()

Account Status and Error Handling

Always check account status before sync. Listen for .CKAccountChanged.

func checkiCloudStatus() async throws -> CKAccountStatus {

    let status = try await CKContainer.default().accountStatus()

    switch status {

    case .available: return status

    case .noAccount: throw SyncError.noiCloudAccount

    case .restricted: throw SyncError.restricted

    case .temporarilyUnavailable: throw SyncError.temporarilyUnavailable

    case .couldNotDetermine: throw SyncError.unknown

    @unknown default: throw SyncError.unknown

    }

}

CKError Handling

Error Code

Strategy

.networkFailure, .networkUnavailable

Queue for retry when network returns

.serverRecordChanged

Three-way merge (see Conflict Resolution)

.requestRateLimited, .zoneBusy, .serviceUnavailable

Retry after retryAfterSeconds

.quotaExceeded

Notify user; reduce data usage

.notAuthenticated

Prompt iCloud sign-in

.partialFailure

Inspect partialErrorsByItemID per item

.changeTokenExpired

Reset token, refetch all changes

.userDeletedZone

Recreate zone and re-upload data

func handleCloudKitError(_ error: Error) {

    guard let ckError = error as? CKError else { return }

    switch ckError.code {

    case .networkFailure, .networkUnavailable:

        scheduleRetryWhenOnline()

    case .serverRecordChanged:

        resolveConflict(ckError)

    case .requestRateLimited, .zoneBusy, .serviceUnavailable:

        let delay = ckError.retryAfterSeconds ?? 3.0

        scheduleRetry(after: delay)

    case .quotaExceeded:

        notifyUserStorageFull()

    case .partialFailure:

        if let partial = ckError.partialErrorsByItemID {

            for (_, itemError) in partial { handleCloudKitError(itemError) }

        }

    case .changeTokenExpired:

        resetChangeToken()

    case .userDeletedZone:

        recreateZoneAndResync()

    default: logError(ckError)

    }

}

Conflict Resolution

When saving a record that changed server-side, CloudKit returns

.serverRecordChanged with three record versions. Always merge into

serverRecord -- it has the correct change tag.

func resolveConflict(_ error: CKError) {

    guard error.code == .serverRecordChanged,

          let ancestor = error.ancestorRecord,

          let client = error.clientRecord,

          let server = error.serverRecord

    else { return }

    // Merge client changes into server record

    for key in client.changedKeys() {

        if server[key] == ancestor[key] {

            server[key] = client[key]           // Server unchanged, use client

        } else if client[key] == ancestor[key] {

            // Client unchanged, keep server (already there)

        } else {

            server[key] = mergeValues(          // Both changed, custom merge

                ancestor: ancestor[key], client: client[key], server: server[key])

        }

    }

    Task { try await CKContainer.default().privateCloudDatabase.save(server) }

}

Common Mistakes

DON'T: Perform sync operations without checking account status.

DO: Check CKContainer.accountStatus() first; handle .noAccount.

// WRONG

try await privateDB.save(record)

// CORRECT

guard try await CKContainer.default().accountStatus() == .available

else { throw SyncError.noiCloudAccount }

try await privateDB.save(record)

DON'T: Ignore .serverRecordChanged errors.

DO: Implement three-way merge using ancestor, client, and server records.

DON'T: Store user-specific data in the public database.

DO: Use private database for personal data; public only for app-wide content.

DON'T: Assume data is available immediately after save.

DO: Update local cache optimistically and reconcile on fetch.

DON'T: Poll for changes on a timer.

DO: Use CKDatabaseSubscription or CKSyncEngine for push-based sync.

// WRONG

Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { _ in fetchAll() }

// CORRECT

let sub = CKDatabaseSubscription(subscriptionID: "db-changes")

sub.notificationInfo = CKSubscription.NotificationInfo()

sub.notificationInfo?.shouldSendContentAvailable = true

try await privateDB.save(sub)

DON'T: Retry immediately on rate limiting.

DO: Use CKError.retryAfterSeconds to wait the required duration.

DON'T: Merge conflict changes into clientRecord.

DO: Always merge into serverRecord -- it has the correct change tag.

DON'T: Pass nil change token on every fetch.

DO: Persist change tokens to disk and supply them on subsequent fetches.

Review Checklist

  • iCloud + CloudKit capability enabled in Signing &#x26; Capabilities
  • Account status checked before sync; .noAccount handled gracefully
  • Private database used for user data; public only for shared content
  • CKError.serverRecordChanged handled with three-way merge into serverRecord
  • Network failures queued for retry; retryAfterSeconds respected
  • CKDatabaseSubscription or CKSyncEngine used for push-based sync
  • Change tokens persisted to disk; changeTokenExpired resets and refetches
  • .partialFailure errors inspected per-item via partialErrorsByItemID
  • .userDeletedZone handled by recreating zone and resyncing
  • SwiftData CloudKit models use optionals, no #Unique, .externalStorage for large data
  • NSUbiquitousKeyValueStore.didChangeExternallyNotification observed
  • Sensitive data uses encryptedValues on CKRecord (not plain fields)
  • CKSyncEngine state serialization persisted across launches (iOS 17+)

References

  • See references/cloudkit-patterns.md for CKFetchRecordZoneChangesOperation

incremental sync, CKShare collaboration, record zone management, CKAsset

file storage, batch operations, and CloudKit Dashboard usage.

BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card