CloudKit and iCloud Sync: Multi-Device Data with CKContainer


Your user edits a Pixar movie watchlist on their iPhone during lunch, opens the iPad on the couch that evening, and expects every change to be there. No sign-in screen, no “sync now” button, no conflict dialog. CloudKit makes this possible — but only if you understand its architecture beyond the WWDC demo.

This guide covers the full CloudKit stack: containers, databases, records, queries, subscriptions, conflict resolution, and the NSPersistentCloudKitContainer shortcut. We will not cover ShareDB or sharing-specific collaboration flows — those deserve their own deep dive.

Contents

The Problem

Consider a straightforward model for tracking favorite Pixar movies. You persist it locally and everything works on a single device.

import Foundation

struct PixarMovie {
    let id: UUID
    var title: String
    var director: String
    var rating: Int // 1-5 stars
    var watchedDate: Date?
    var notes: String
}

The moment a second device enters the picture, you face three hard problems simultaneously: how to push local changes to iCloud, how to pull remote changes without blocking the UI, and how to reconcile conflicting edits when both devices modify the same record offline. Raw UserDefaults or local-only persistence cannot solve any of these. CloudKit can.

CloudKit Architecture: Containers, Databases, and Zones

CloudKit organizes data into a strict hierarchy: Container > Database > Zone > Record. Understanding this hierarchy is non-negotiable before writing any sync code.

CKContainer

A CKContainer is the top-level namespace for your app’s CloudKit data. Every app gets a default container matching its bundle identifier, but you can create custom containers to share data between apps.

import CloudKit

// Default container — matches your bundle ID
let defaultContainer = CKContainer.default()

// Custom container — shared between multiple apps
let sharedContainer = CKContainer(identifier: "iCloud.com.pixar.movietracker")

CKDatabase

Each container exposes three databases:

  • Private — Data visible only to the signed-in user. This is where personal watchlists live. Each user gets 1 GB free.
  • Public — Data visible to all users of the app. Think of this as a read-heavy community database. The developer pays for storage.
  • Shared — Records from one user’s private database shared with specific other users via CKShare.
let privateDB = defaultContainer.privateCloudDatabase
let publicDB = defaultContainer.publicCloudDatabase
let sharedDB = defaultContainer.sharedCloudDatabase

CKRecordZone

Zones are partitions within a database. The private database has a default zone, but custom zones unlock atomic commits and change tokens — both critical for reliable sync.

let movieZone = CKRecordZone(zoneName: "PixarMovies")

// Create the zone before saving records into it
try await privateDB.save(movieZone)

Tip: Always use a custom zone in the private database. The default zone does not support CKFetchRecordZoneChangesOperation, which is the backbone of incremental sync.

Working with CKRecord and CKQuery

Creating and Saving Records

A CKRecord is CloudKit’s key-value container. It is not a Swift struct — it is a reference type with string-keyed fields.

func createMovieRecord(
    from movie: PixarMovie,
    in zone: CKRecordZone
) -> CKRecord {
    let recordID = CKRecord.ID(
        recordName: movie.id.uuidString,
        zoneID: zone.zoneID
    )
    let record = CKRecord(recordType: "PixarMovie", recordID: recordID)
    record["title"] = movie.title as CKRecordValue
    record["director"] = movie.director as CKRecordValue
    record["rating"] = movie.rating as CKRecordValue
    record["watchedDate"] = movie.watchedDate as CKRecordValue?
    record["notes"] = movie.notes as CKRecordValue
    return record
}

Saving uses the modern async/await API introduced in iOS 15:

func save(
    movie: PixarMovie,
    to database: CKDatabase,
    in zone: CKRecordZone
) async throws {
    let record = createMovieRecord(from: movie, in: zone)
    let savedRecord = try await database.save(record)
    print("Saved '\(savedRecord["title"] ?? "Unknown")'")
}

Querying Records

CKQuery uses NSPredicate to filter records server-side. Only fields with CloudKit indexes can be queried.

func fetchTopRatedMovies(
    from database: CKDatabase,
    in zone: CKRecordZone
) async throws -> [CKRecord] {
    let predicate = NSPredicate(format: "rating >= %d", 4)
    let query = CKQuery(recordType: "PixarMovie", predicate: predicate)
    query.sortDescriptors = [
        NSSortDescriptor(key: "watchedDate", ascending: false)
    ]

    let (results, _) = try await database.records(
        matching: query,
        inZoneWith: zone.zoneID,
        resultsLimit: 50
    )

    return results.compactMap { _, result in
        try? result.get()
    }
}

Note: CloudKit queries have a 400 KB response limit per batch. The cursor returned by the results tuple lets you paginate through larger datasets. Always handle the cursor in production code.

Incremental Sync with Change Tokens

Full fetches are expensive. In production, you track a server change token and only fetch what changed since the last sync. This is why custom zones matter.

actor CloudKitSyncEngine {
    private let database: CKDatabase
    private let zoneID: CKRecordZone.ID
    private var lastChangeToken: CKServerChangeToken?

    init(database: CKDatabase, zoneID: CKRecordZone.ID) {
        self.database = database
        self.zoneID = zoneID
        self.lastChangeToken = loadTokenFromDisk()
    }

    func fetchChanges() async throws -> (
        changed: [CKRecord],
        deleted: [CKRecord.ID]
    ) {
        var changedRecords: [CKRecord] = []
        var deletedIDs: [CKRecord.ID] = []

        let changes = database.recordZoneChanges(
            inZoneWith: zoneID,
            since: lastChangeToken
        )

        for try await change in changes {
            switch change {
            case .modified(let record):
                changedRecords.append(record)
            case .deleted(let recordID, _):
                deletedIDs.append(recordID)
            }
        }

        // Persist the new token for the next sync cycle
        return (changedRecords, deletedIDs)
    }

    private func loadTokenFromDisk() -> CKServerChangeToken? {
        guard let data = UserDefaults.standard.data(
            forKey: "ckChangeToken"
        ) else { return nil }
        return try? NSKeyedUnarchiver.unarchivedObject(
            ofClass: CKServerChangeToken.self,
            from: data
        )
    }

    func saveTokenToDisk(_ token: CKServerChangeToken) {
        let data = try? NSKeyedArchiver.archivedData(
            withRootObject: token,
            requiringSecureCoding: true
        )
        UserDefaults.standard.set(data, forKey: "ckChangeToken")
    }
}

Apple Docs: CKFetchRecordZoneChangesOperation — CloudKit

Subscriptions and Real-Time Notifications

Rather than polling for changes, CloudKit can push silent notifications when records change.

func subscribeToMovieChanges(
    in database: CKDatabase,
    zoneID: CKRecordZone.ID
) async throws {
    let subscription = CKRecordZoneSubscription(
        zoneID: zoneID,
        subscriptionID: "pixar-movie-changes"
    )

    let notificationInfo = CKSubscription.NotificationInfo()
    notificationInfo.shouldSendContentAvailable = true // Silent push
    subscription.notificationInfo = notificationInfo

    try await database.save(subscription)
}

When the silent push arrives, your AppDelegate (or BGAppRefreshTask handler) calls into the sync engine to pull incremental changes:

func application(
    _ application: UIApplication,
    didReceiveRemoteNotification userInfo: [AnyHashable: Any]
) async -> UIBackgroundFetchResult {
    let notification = CKNotification(
        fromRemoteNotificationDictionary: userInfo
    )

    guard notification?.subscriptionID == "pixar-movie-changes" else {
        return .noData
    }

    do {
        let changes = try await syncEngine.fetchChanges()
        let total = changes.changed.count + changes.deleted.count
        return total > 0 ? .newData : .noData
    } catch {
        return .failed
    }
}

Warning: Silent pushes are best-effort. iOS throttles them aggressively, especially on low-power mode. Never rely on subscriptions as your only sync trigger — always reconcile on app launch and on sceneDidBecomeActive.

NSPersistentCloudKitContainer: The Core Data Shortcut

If your app already uses Core Data (or you want automatic sync without managing CKRecord manually), NSPersistentCloudKitContainer replaces NSPersistentContainer and handles the entire sync lifecycle.

import CoreData

final class PixarPersistenceController {
    static let shared = PixarPersistenceController()

    let container: NSPersistentCloudKitContainer

    private init() {
        container = NSPersistentCloudKitContainer(
            name: "PixarMovieTracker"
        )

        guard let description = container
            .persistentStoreDescriptions.first else {
            fatalError("No persistent store description found")
        }

        // Enable CloudKit sync
        description.cloudKitContainerOptions =
            NSPersistentCloudKitContainerOptions(
                containerIdentifier: "iCloud.com.pixar.movietracker"
            )

        // Enable remote change notifications for UI updates
        description.setOption(
            true as NSNumber,
            forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey
        )

        // Enable persistent history tracking (required for sync)
        description.setOption(
            true as NSNumber,
            forKey: NSPersistentHistoryTrackingKey
        )

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

        container.viewContext.automaticallyMergesChangesFromParent = true
        container.viewContext.mergePolicy =
            NSMergeByPropertyObjectTrumpMergePolicy
    }
}

The container mirrors your Core Data model to CloudKit automatically. Every NSManagedObject save becomes a CKRecord modification. Every remote change gets merged into the local store. You write Core Data code; CloudKit sync happens behind the scenes.

Listening for Remote Changes

When another device pushes changes, you need to refresh your UI:

import Combine

final class MovieListViewModel: ObservableObject {
    @Published var movies: [PixarMovieEntity] = []
    private var cancellables = Set<AnyCancellable>()

    init() {
        // React to remote CloudKit changes merged into Core Data
        NotificationCenter.default
            .publisher(for: .NSPersistentStoreRemoteChange)
            .receive(on: DispatchQueue.main)
            .sink { [weak self] _ in
                self?.fetchMovies()
            }
            .store(in: &cancellables)

        fetchMovies()
    }

    private func fetchMovies() {
        let context = PixarPersistenceController.shared
            .container.viewContext
        let request = PixarMovieEntity.fetchRequest()
        request.sortDescriptors = [
            NSSortDescriptor(
                keyPath: \PixarMovieEntity.watchedDate,
                ascending: false
            )
        ]
        movies = (try? context.fetch(request)) ?? []
    }
}

Tip: In SwiftUI, if you are using SwiftData instead of Core Data, the equivalent setup uses .modelContainer with a cloudKitDatabase option. See our SwiftData + CloudKit Sync post for that approach.

Conflict Resolution Strategies

Two devices editing the same Pixar movie rating offline is not an edge case — it is Tuesday. CloudKit uses a last-writer-wins model by default at the record level, but you have tools to do better.

Server Record vs. Client Record

When a save fails with CKError.serverRecordChanged, CloudKit provides three versions of the record in the error’s userInfo:

func saveWithConflictResolution(
    record: CKRecord,
    to database: CKDatabase
) async throws -> CKRecord {
    do {
        return try await database.save(record)
    } catch let error as CKError
        where error.code == .serverRecordChanged {
        // Three versions available:
        guard let serverRecord = error.serverRecord,
              let clientRecord = error.clientRecord else {
            throw error
        }
        // error.ancestorRecord is the common ancestor (if available)

        // Strategy: merge field-by-field
        let resolved = mergeRecords(
            server: serverRecord,
            client: clientRecord
        )
        return try await database.save(resolved)
    }
}

private func mergeRecords(
    server: CKRecord,
    client: CKRecord
) -> CKRecord {
    // Use the server record as base (has the correct change tag)
    let merged = server

    // Field-level merge: client's rating wins, server's notes win
    // Your merge strategy depends on your domain
    if let clientRating = client["rating"] as? Int,
       let serverRating = server["rating"] as? Int {
        merged["rating"] = max(clientRating, serverRating)
            as CKRecordValue
    }

    // For text fields, prefer the longer version
    if let clientNotes = client["notes"] as? String,
       let serverNotes = server["notes"] as? String {
        merged["notes"] = (clientNotes.count > serverNotes.count
            ? clientNotes : serverNotes) as CKRecordValue
    }

    return merged
}

Merge Policies for NSPersistentCloudKitContainer

When using NSPersistentCloudKitContainer, the merge policy on your NSManagedObjectContext determines conflict behavior:

PolicyBehaviorUse When
NSMergeByPropertyStoreTrumpMergePolicyIn-memory changes lose to storeYou trust server data over local edits
NSMergeByPropertyObjectTrumpMergePolicyIn-memory changes win over storeLocal edits should override remote
NSRollbackMergePolicyDiscard in-memory changes entirelyYou want to always reflect server state

Warning: NSErrorMergePolicy (the default) throws on any conflict. This will cause sync failures in production. Always set an explicit merge policy.

Schema Versioning Across App Updates

CloudKit schemas are append-only in production. You cannot delete fields or rename record types once the schema is deployed. Plan for this.

The Additive-Only Rule

// Version 1: Original schema
// record["title"] = "Toy Story"
// record["rating"] = 5

// Version 2: Adding a new field — SAFE
// record["title"] = "Toy Story"
// record["rating"] = 5
// record["isFavorite"] = true  // ← New field, older clients ignore it

// Version 3: Renaming a field — UNSAFE in production
// record["movieTitle"] = "Toy Story"  // ← Old clients still write to "title"

Handling Missing Fields Gracefully

When older app versions create records without newer fields, your code must handle nil values:

func parseMovieRecord(_ record: CKRecord) -> PixarMovie {
    PixarMovie(
        id: UUID(uuidString: record.recordID.recordName) ?? UUID(),
        title: record["title"] as? String ?? "Unknown Movie",
        director: record["director"] as? String ?? "Unknown Director",
        rating: record["rating"] as? Int ?? 0,
        watchedDate: record["watchedDate"] as? Date,
        notes: record["notes"] as? String ?? "",
        isFavorite: record["isFavorite"] as? Bool ?? false // ← v2 field
    )
}

Testing Schema Changes

Use the CloudKit Dashboard at icloud.developer.apple.com to inspect your development and production schemas. In development mode, you can reset the schema freely. Once promoted to production, changes are permanent.

Tip: Maintain a versioned mapping document that tracks which app version introduced which CloudKit fields. This saves hours of debugging when users on different app versions sync with each other.

Performance Considerations

Batch operations matter. Saving 50 records one at a time means 50 network round trips. Use CKModifyRecordsOperation to batch them:

func batchSave(
    records: [CKRecord],
    to database: CKDatabase
) async throws {
    let (saved, _) = try await database.modifyRecords(
        saving: records,
        deleting: [],
        savePolicy: .changedKeys,
        atomically: true
    )
    print("Batch saved \(saved.count) records")
}

Query indexes are not free. Every indexed field increases CloudKit storage overhead and write latency. Only index fields you actually query against.

Rate limiting is real. CloudKit throttles requests per-user with CKError.requestRateLimited. The error includes a retryAfterSeconds value — respect it.

func saveWithRetry(
    record: CKRecord,
    to database: CKDatabase,
    maxAttempts: Int = 3
) async throws -> CKRecord {
    for attempt in 1...maxAttempts {
        do {
            return try await database.save(record)
        } catch let error as CKError
            where error.code == .requestRateLimited {
            let delay = error.retryAfterSeconds ?? Double(attempt * 2)
            try await Task.sleep(for: .seconds(delay))
        }
    }
    throw CKError(.requestRateLimited)
}

Asset size limits. A single CKAsset can be up to 250 MB, but transfers over cellular are limited to 50 MB by default. For large assets (movie poster images, for example), consider progressive loading or offering a Wi-Fi-only sync option.

Apple Docs: CKModifyRecordsOperation — CloudKit

When to Use (and When Not To)

ScenarioRecommendation
Multi-device sync for a single user’s dataCloudKit private database is the natural fit. Free, no server.
Public read-heavy data (leaderboards, catalog)CloudKit public database works but you pay at scale.
App already uses Core Data or SwiftDataNSPersistentCloudKitContainer or cloudKitDatabase gives sync with minimal code.
Real-time collaboration (Google Docs-style)Subscriptions have seconds-to-minutes latency. Use WebSockets.
User-to-user sharing of specific recordsCKShare with the shared database handles this, but the API is complex.
Cross-platform (Android/Web)CloudKit is Apple-only. Use Firebase or a custom backend.
Sensitive data requiring E2E encryptionCloudKit encrypts at rest, but Apple holds the keys. Add your own layer.

Summary

  • CloudKit organizes data as Container > Database > Zone > Record. Always use custom zones in the private database for incremental sync support.
  • Use change tokens with CKFetchRecordZoneChangesOperation (or the async recordZoneChanges API) to avoid full-fetching every sync cycle.
  • NSPersistentCloudKitContainer automates Core Data-to-CloudKit mirroring, but you must set an explicit merge policy and listen for .NSPersistentStoreRemoteChange notifications.
  • Conflict resolution requires handling CKError.serverRecordChanged with a field-level merge strategy — not just retrying the save.
  • CloudKit schemas are append-only in production. Design for forward compatibility from day one.

Ready to add CloudKit sync to a SwiftData app? Continue with SwiftData + CloudKit Sync for the modern persistence approach, or jump to Build a Synced Notes App to put these concepts into a complete project.