6.3 — CloudKit
Opening scenario
A user emails you: “I bought your app on my iPhone in January. Got an iPad last week. My data isn’t there. Refund.”
You have three options:
- Build a backend (Postgres + auth + a sync protocol + scaling + GDPR + 24/7 on-call).
- Use Firebase (free until you’re successful, then $$$, plus Google sees every byte).
- Ship CloudKit and let Apple’s iCloud account on the device do all of it for free, with end-to-end encryption on private data, no signup screen, no password.
For a consumer app on the Apple platform, the answer is almost always #3 — until you need cross-platform, server-side computation, or a feature CloudKit doesn’t support (full-text search across users, complex analytics, multi-region). Then you add a backend alongside CloudKit, not instead of it.
| Context | What it usually means |
|---|---|
Reads “CKContainer” | Has wired up CloudKit at least once |
| Reads “private / public / shared database” | Understands the privacy model |
Reads “CKSubscription” | Has set up push-driven sync |
| Reads “CKRecord vs NSManagedObject” | Knows the difference between raw CloudKit and Core Data + CloudKit |
| Reads “schema is auto-promoted in dev” | Has been bitten by the production schema being empty |
Concept → Why → How → Code
Concept
CloudKit is Apple’s cloud database, identity, and push service. Every container has three databases:
- Public — shared by all users; readable by anyone, writable by the record creator (or anyone you grant permissions). Storage counts against your app’s quota, not the user’s.
- Private — per-user data; encrypted; storage counts against that user’s iCloud quota. The user pays. You don’t see the data.
- Shared — records the user has explicitly shared with other iCloud accounts (think collaborative documents).
Records (CKRecord) are key-value bags with typed fields: strings, numbers, dates, bytes, references to other records, asset URLs (large blobs stored separately and lazy-loaded), and location.
Why
- Free for users with iCloud. No signup screen. Identity comes from the iCloud account on the device.
- Free for you up to generous limits (10 GB asset storage / 100 MB database / 2 GB/day data transfer per user — and the user-private storage is on the user’s iCloud plan, not yours).
- Push out of the box.
CKSubscriptiontriggers an APNs notification to other devices when a record changes — no APNs server work on your side. - End-to-end encryption on private data when the user has Advanced Data Protection enabled.
How — initial setup
- Enable the CloudKit capability in your target’s Signing & Capabilities tab.
- Xcode creates a default container named
iCloud.com.yourcompany.YourApp. Open it in the CloudKit Dashboard (CloudKit Consolebutton in the capability pane, or icloud.developer.apple.com/dashboard). - In the dashboard, you’ll see Development and Production environments. Records and indexes you create in code show up automatically in Development; you must explicitly Deploy Schema to Production before App Store builds can read them.
Code — write a record
import CloudKit
final class JournalCloudStore {
private let container = CKContainer(identifier: "iCloud.com.example.Journal")
private var database: CKDatabase { container.privateCloudDatabase }
func save(title: String, body: String) async throws -> CKRecord {
let record = CKRecord(recordType: "Entry")
record["title"] = title as CKRecordValue
record["body"] = body as CKRecordValue
record["createdAt"] = Date() as CKRecordValue
return try await database.save(record)
}
}
After running this once, refresh the CloudKit Dashboard → Schema. You’ll see the Entry record type with the three fields. This auto-promotion of schema from code is Development-only behavior; in Production you set the schema deliberately and deploy.
Code — query
func fetchRecentEntries(limit: Int = 50) async throws -> [CKRecord] {
let predicate = NSPredicate(value: true)
let query = CKQuery(recordType: "Entry", predicate: predicate)
query.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]
var results: [CKRecord] = []
let (matchedResults, _) = try await database.records(matching: query, resultsLimit: limit)
for (_, result) in matchedResults {
if let record = try? result.get() { results.append(record) }
}
return results
}
CKQuery uses NSPredicate strings. The available operators are limited (no joins, no LIKE with arbitrary regex, sortable fields must be in a queryable index in production).
Code — subscriptions for real-time updates
func subscribeToEntries() async throws {
let subscription = CKQuerySubscription(
recordType: "Entry",
predicate: NSPredicate(value: true),
subscriptionID: "all-entries",
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
)
let info = CKSubscription.NotificationInfo()
info.shouldSendContentAvailable = true // silent push to wake the app
info.alertBody = "" // empty = no banner
subscription.notificationInfo = info
_ = try await database.save(subscription)
}
Then in your UNUserNotificationCenter delegate (or AppDelegate’s didReceiveRemoteNotification), fetch the changed records via CKDatabase.fetchDatabaseChanges and fetchZoneChanges. This is the foundation of an offline-first sync engine.
The change token pattern
Production sync is delta-based. CloudKit returns a CKServerChangeToken after every change fetch; you persist it; the next fetch passes it back and you get only what changed since that token. The pattern:
func sync() async throws {
let savedToken = loadSavedChangeToken()
let operation = CKFetchRecordZoneChangesOperation(
recordZoneIDs: [defaultZone.zoneID],
configurationsByRecordZoneID: [
defaultZone.zoneID: CKFetchRecordZoneChangesOperation.ZoneConfiguration(previousServerChangeToken: savedToken)
]
)
operation.recordWasChangedBlock = { id, result in /* upsert locally */ }
operation.recordWithIDWasDeletedBlock = { id, _ in /* delete locally */ }
operation.recordZoneChangeTokensUpdatedBlock = { _, token, _ in
if let token { self.saveChangeToken(token) }
}
operation.qualityOfService = .userInitiated
database.add(operation)
}
Custom zones (CKRecordZone) are required for fetching deltas in the private database. The default zone does not support fetchRecordZoneChanges — a gotcha that has cost more than one engineer a weekend.
Asset uploads
let image = UIImage(named: "cover")!
let url = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID()).jpg")
try image.jpegData(compressionQuality: 0.8)!.write(to: url)
let record = CKRecord(recordType: "Entry")
record["cover"] = CKAsset(fileURL: url)
_ = try await database.save(record)
CloudKit stores the asset separately and the CKAsset.fileURL on retrieval is a local cached URL — read once, cache the data, and don’t assume the URL stays valid across launches.
In the wild
- Apple Notes, Reminders, Photos, Calendar, Mail VIPs, Safari bookmarks/history/tabs — all CloudKit private database, mostly via
NSPersistentCloudKitContainer. - News, Maps Guides, Fitness sharing — CloudKit public database.
- iA Writer, Ulysses, Day One — CloudKit private database for document sync.
- Working Copy uses CloudKit shared database for collaborative repos.
Notably not CloudKit: anything with a Web client (Things 3 stayed off CloudKit for years for this reason; Notes still has no real Web client because of it), anything needing server-side search across all users (CloudKit indexes are per-user-private or fully-public; you can’t run cross-user queries on private data, and that’s a feature).
Common misconceptions
- “CloudKit is a backend.” It is a distributed key-value store with subscriptions and identity. It is not a place to run server code. You cannot run a
WHERE userId IN (?, ?, ?)query on private data. For business logic you still need a server, or you push all logic to the client. - “
CKQueryis like SQL.” It isNSPredicate-based with limited operators, can’t join across record types, requires queryable indexes in production, and has a default result limit of 100. - “Subscriptions deliver data.” They deliver a push notification that something changed. You still have to fetch the changes via
CKFetchRecordZoneChangesOperation. The notification carries a small payload, not the new record. - “Public database is free unlimited storage.” It counts against your app’s CloudKit quota (which scales with your active user count). Going viral with public records can become expensive; rate-limit your writes.
- “Development and Production share data.” They are separate environments, separate databases, separate schemas. A record you wrote in Development is invisible to a TestFlight build pointing at Production. Schema must be explicitly deployed via the dashboard before Production code can use a new record type or field.
Seasoned engineer’s take
CloudKit is the best-kept secret in Apple’s developer toolkit. It costs $0, requires no auth screen, and the privacy story is unbeatable — when a user complains that an app “isn’t syncing properly,” check if iCloud is signed in before checking your code, because nine times out of ten that’s the answer.
The cost of CloudKit is control. You can’t see your users’ private data — not for support, not for debugging, not ever. You can’t run aggregations across users. You can’t migrate schemas with custom logic running on a server. You can’t query Friend A’s data from Friend B’s device unless A has shared it via CKShare. When these constraints become limits, you bolt on a small backend for the things CloudKit can’t do, and keep CloudKit for what it does well (user-owned data sync).
For a side project, ship CloudKit on day one. The “no signup, just open the app and it syncs to all your devices” experience is genuinely magical, and it costs you almost nothing in code. For a venture-backed cross-platform startup, evaluate carefully — you’ll likely need both CloudKit (iOS sync) and a real backend (Web, Android, business logic), and the duplication is real.
TIP: When iterating on schema in Development, the auto-promotion happens only on the first write of a new record type or field. If you change a field’s type, you must reset the Development environment in the dashboard. There is no “alter table” — fields are forever once promoted to Production.
WARNING:
accountStatuscan be.noAccount,.restricted,.couldNotDetermine, or.available. Always check before any CloudKit call and handle gracefully — millions of users (kids, parental control accounts, Mac Minis without iCloud sign-in) have no usable iCloud account, and crashing or erroring onaccountStatus != .availableis one of the most common one-star reviews for CloudKit apps.
Interview corner
Junior: “What’s the difference between public, private, and shared databases?”
Public is one shared database for all users of the app; storage counts against the app’s quota. Private is per-user, encrypted, stored in their iCloud, counting against their quota. Shared contains records the user has accepted invitations to from other users via
CKShare.
Mid: “Walk me through delta sync with CKFetchRecordZoneChangesOperation.”
Use custom record zones in the private database. Persist the
CKServerChangeTokenreturned after each fetch. Pass it back next time to receive only changes since that token. Set up a silent-pushCKQuerySubscriptionso the device gets woken when a change happens. Apply changes locally inside a transaction so partial fetches don’t leave inconsistent state.
Senior: “Design the offline-first sync for a collaborative notes app where two devices edit the same note while offline, then both come online.”
Two layers: a local store of truth (Core Data or SwiftData), and a CloudKit mirror via custom zones with change tokens. Each note carries a
modifiedAtand a vector or simple last-writer-wins resolution. On reconnect, fetch zone changes; if local has unsynced edits to a record that arrived modified, present a conflict UI or auto-merge per-field (CRDT for text bodies if budget allows). UseCKModifyRecordsOperationwithsavePolicy = .ifServerRecordUnchangedto detect server-side concurrent edits and re-resolve. For real collaborative editing (Google-Docs-style), CloudKit isn’t enough — you’d add a WebSocket layer for live cursor and OT/CRDT ops, keeping CloudKit for the durable snapshot.
Red flag: “We poll CloudKit every 30 seconds for updates.”
Tells the interviewer you’ve never read the CloudKit docs. Polling wastes battery, hits rate limits, and is exactly what subscriptions exist to prevent.
Lab preview
Lab 6.2 — CloudKit Sync App builds a recipe-sharing app with both private (your saved recipes) and public (community recipes) databases, plus CKSubscription-driven real-time updates.
Next: Core Data + CloudKit