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:

  1. Build a backend (Postgres + auth + a sync protocol + scaling + GDPR + 24/7 on-call).
  2. Use Firebase (free until you’re successful, then $$$, plus Google sees every byte).
  3. 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.

ContextWhat it usually means
Reads “CKContainerHas wired up CloudKit at least once
Reads “private / public / shared database”Understands the privacy model
Reads “CKSubscriptionHas 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. CKSubscription triggers 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

  1. Enable the CloudKit capability in your target’s Signing & Capabilities tab.
  2. Xcode creates a default container named iCloud.com.yourcompany.YourApp. Open it in the CloudKit Dashboard (CloudKit Console button in the capability pane, or icloud.developer.apple.com/dashboard).
  3. 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

  1. “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.
  2. CKQuery is like SQL.” It is NSPredicate-based with limited operators, can’t join across record types, requires queryable indexes in production, and has a default result limit of 100.
  3. “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.
  4. “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.
  5. “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: accountStatus can 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 on accountStatus != .available is 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 CKServerChangeToken returned after each fetch. Pass it back next time to receive only changes since that token. Set up a silent-push CKQuerySubscription so 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 modifiedAt and 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). Use CKModifyRecordsOperation with savePolicy = .ifServerRecordUnchanged to 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