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
- CloudKit Architecture
- Working with CKRecord and CKQuery
- Subscriptions and Real-Time Notifications
- NSPersistentCloudKitContainer
- Conflict Resolution Strategies
- Schema Versioning Across App Updates
- Performance Considerations
- When to Use (and When Not To)
- Summary
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
cursorreturned 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
.modelContainerwith acloudKitDatabaseoption. 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:
| Policy | Behavior | Use When |
|---|---|---|
NSMergeByPropertyStoreTrumpMergePolicy | In-memory changes lose to store | You trust server data over local edits |
NSMergeByPropertyObjectTrumpMergePolicy | In-memory changes win over store | Local edits should override remote |
NSRollbackMergePolicy | Discard in-memory changes entirely | You 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)
| Scenario | Recommendation |
|---|---|
| Multi-device sync for a single user’s data | CloudKit 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 SwiftData | NSPersistentCloudKitContainer 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 records | CKShare 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 encryption | CloudKit 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 asyncrecordZoneChangesAPI) to avoid full-fetching every sync cycle. NSPersistentCloudKitContainerautomates Core Data-to-CloudKit mirroring, but you must set an explicit merge policy and listen for.NSPersistentStoreRemoteChangenotifications.- Conflict resolution requires handling
CKError.serverRecordChangedwith 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.