CloudKit Without a Server: Offline-First Sync in Production#
When I was designing sync for MeteoHealth — an app that tracks a user's health metrics across iPhone, iPad and Apple Watch — the first question wasn't "which framework," it was "do I even need a server." Personal health data, three platforms, a hard requirement to work offline on a plane or in a hospital basement — and zero appetite for maintaining a backend, a database, and DevOps for a single app.
The answer I landed on after several months in production: you don't need a server if you design offline-first sync correctly on top of CloudKit. This article isn't a rehash of Apple's docs — it's the concrete experience: what worked, what broke, and what I'd do differently knowing what I know now.
Why I chose between CloudKit, Firebase and Supabase#
At the start this looked like a standard BaaS decision. Firebase is mature, cross-platform, huge community. Supabase is open, Postgres under the hood, pleasant DX. CloudKit is Apple-only, but it's baked into the OS.
Three factors specific to health data made the call for me:
- Data physically lives in the user's own iCloud, not on servers I have to administer, patch and secure. That's not just convenience — it's a smaller attack surface and less liability for storing sensitive data.
- Authentication is already solved. The user already has an Apple ID; I don't have to build sign-up, password recovery, and everything that comes with it for health data.
- Zero infrastructure. No server that can go down at 3am. No hosting bill that grows with the user base.
The trade-off is vendor lock-in to Apple and a lower ceiling on customization: complex cross-record transactions and arbitrary server-side queries are harder in CloudKit than in Postgres. For an app that lives entirely inside the Apple ecosystem, that trade-off was worth making.
| Criterion | CloudKit | Firebase | Supabase |
|---|---|---|---|
| Own server | Not required | Not required (managed) | Requires self-host or Supabase Cloud |
| Data storage | User's private iCloud database | Google servers | Postgres (managed/self-host) |
| Cross-platform | Apple ecosystem only | iOS/Android/Web | iOS/Android/Web |
| Authentication | Apple ID out of the box | Firebase Auth (setup required) | Supabase Auth (setup required) |
| Offline-first | Built into Core Data via NSPersistentCloudKitContainer | Firestore offline cache | Requires manual implementation |
| Cost at scale | Apple's free per-user quotas | Grows with Firestore query spend | Grows with the Postgres instance |
Architecture: Core Data on top of NSPersistentCloudKitContainer#
The first version of MeteoHealth's sync is built on NSPersistentCloudKitContainer — the fastest path to offline-first behavior, since Core Data already works locally without a network, and NSPersistentCloudKitContainer adds CloudKit mirroring on top of that.
import CoreData
import CloudKit
final class PersistenceController {
static let shared = PersistenceController()
let container: NSPersistentCloudKitContainer
init(inMemory: Bool = false) {
container = NSPersistentCloudKitContainer(name: "MeteoHealthModel")
guard let description = container.persistentStoreDescriptions.first else {
fatalError("No persistent store description found")
}
// Required for CloudKit mirroring: history tracking + remote change notifications
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
containerIdentifier: "iCloud.pro.dodecaidr.meteohealth"
)
container.loadPersistentStores { _, error in
if let error = error as NSError? {
fatalError("Unresolved error loading store: \(error), \(error.userInfo)")
}
}
container.viewContext.automaticallyMergesChangesFromParent = true
// Field-level last-writer-wins. Good enough for most entities,
// but not for health metrics where "who wrote last" isn't "who is right".
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
}
}One nuance that isn't obvious from the docs: CloudKit-compatible Core Data models come with hard constraints — every attribute must be optional or have a default value, unique constraints aren't supported, and relationships must be optional. That forced me to rework the data model before writing a single line of sync code: some business invariants that used to live at the schema level moved into application-level validation.
Private database and zones: how sync works across iPhone, iPad and Apple Watch#
CloudKit splits data into three database types: public, private and shared. For personal health data, only the private database fits — it physically lives in the user's own iCloud storage, isn't accessible to other users, and never touches infrastructure I control.
Inside the private database, data is grouped into zones (CKRecordZone). Everything falls into defaultZone by default, but for offline-first sync, custom zones give you a critical advantage: atomic batch save/delete operations within a single zone, plus independent change tokens — you can sync a group of related records (say, all metrics from one doctor visit) as a unit, without risking a partially-applied state on another device.
For MeteoHealth in practice, this meant a single HealthMetrics zone shared across all three platforms — iPhone, iPad and Apple Watch read and write the same zone in the same private database of the same iCloud account. No separate "device sync" server is needed: CloudKit is the transport.
Conflicts in production: what serverRecordChanged actually means#
This is where the real engineering starts. NSPersistentCloudKitContainer resolves conflicts automatically through NSMergePolicy — but at the field level, not the record level, and without access to the "ancestor" version. For most fields that's fine: the field changed later wins. But for health measurements, "who wrote last" and "who is right" are different questions: if a user entered a value on iPhone at 9:00, and a delayed sync delivered an automatic Apple Watch measurement from 8:45 only at 9:05, naive last-writer-wins overwrites the earlier — but not less valid — value.
This is where NSPersistentCloudKitContainer turned out to be too high-level: it doesn't expose the three record versions behind serverRecordChanged — local, server, and ancestor. For the critical entities, I dropped down to raw CKRecord handling via CKSyncEngine.
CKSyncEngine: when to drop below Core Data#
CKSyncEngine, introduced at WWDC23 for iOS 17+, is a lower-level but declarative API: it takes over most of the grunt work (change tokens, retries, batching push notifications) but hands you full control over exactly what gets sent and how conflicts get resolved.
import CloudKit
final class HealthSyncEngine: NSObject, CKSyncEngineDelegate {
private var syncEngine: CKSyncEngine!
private let zoneID = CKRecordZone.ID(zoneName: "HealthMetrics")
func configure(savedState: CKSyncEngine.State.Serialization?) {
let configuration = CKSyncEngine.Configuration(
database: CKContainer(identifier: "iCloud.pro.dodecaidr.meteohealth").privateCloudDatabase,
stateSerialization: savedState,
delegate: self
)
syncEngine = CKSyncEngine(configuration)
}
func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) {
switch event {
case .stateUpdate(let update):
persistState(update.stateSerialization)
case .fetchedRecordZoneChanges(let event):
event.modifications.forEach { applyToLocalStore($0.record) }
event.deletions.forEach { deleteFromLocalStore(recordID: $0.recordID) }
case .sentRecordZoneChanges(let event):
// Records CloudKit rejected because the server version moved on
// while we were offline.
for failure in event.failedRecordSaves {
guard failure.error.code == .serverRecordChanged,
let serverRecord = failure.error.serverRecord else { continue }
let resolved = mergeHealthRecord(local: failure.record, server: serverRecord)
syncEngine.state.add(pendingRecordZoneChanges: [.saveRecord(resolved.recordID)])
stageForNextSave(resolved)
}
default:
break
}
}
func nextRecordZoneChangeBatch(
_ context: CKSyncEngine.SendChangesContext,
syncEngine: CKSyncEngine
) async -> CKSyncEngine.RecordZoneChangeBatch? {
await CKSyncEngine.RecordZoneChangeBatch(
pendingChanges: context.options.scope.pendingRecordZoneChanges
) { recordID in
recordToSave(for: recordID)
}
}
}The key part is failedRecordSaves with the .serverRecordChanged error code. Unlike NSPersistentCloudKitContainer, CKSyncEngine hands me the actual server record — with a fresh recordChangeTag, without which resending would just be rejected again. I build a new record on top of the server version and apply my own business logic:
/// serverRecordChanged gives us the current server record with a valid
/// change tag. Re-sending the local record's stale tag would just be
/// rejected again — we must copy the server record and reapply our fields.
private func mergeHealthRecord(local: CKRecord, server: CKRecord) -> CKRecord {
let merged = server.copy() as! CKRecord
let localRecordedAt = local["recordedAt"] as? Date ?? .distantPast
let serverRecordedAt = server["recordedAt"] as? Date ?? .distantPast
// The freshest measurement wins — not whoever reached the server first.
if localRecordedAt > serverRecordedAt {
merged["value"] = local["value"]
merged["recordedAt"] = local["recordedAt"]
merged["source"] = local["source"]
}
return merged
}This resolves the conflict based on the meaning of the data (actual measurement time), not on the accident of network latency. For the rest of MeteoHealth's less critical entities, I kept NSPersistentCloudKitContainer — there's no point rewriting the whole stack onto CKSyncEngine where property-level last-writer-wins never bites anyone.
Push and subscriptions: how devices learn about changes instantly#
Without push notifications, sync across iPhone, iPad and Apple Watch would only run on a timer or when the app opens — not what a user expects from "one iCloud account." CKDatabaseSubscription watches the entire private database (including new zones) and delivers a silent push on every change:
func setupDatabaseSubscription(database: CKDatabase) async throws {
let subscription = CKDatabaseSubscription(subscriptionID: "meteohealth-private-db-changes")
let notificationInfo = CKSubscription.NotificationInfo()
notificationInfo.shouldSendContentAvailable = true // silent push, no banner
subscription.notificationInfo = notificationInfo
try await database.save(subscription)
}On receiving that push, the app shows the user nothing — it just triggers a fetch of pending changes through CKSyncEngine:
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
guard let notification = CKNotification(fromRemoteNotificationDictionary: userInfo),
notification.subscriptionID == "meteohealth-private-db-changes" else {
completionHandler(.noData)
return
}
Task {
try? await syncEngine.fetchChanges()
completionHandler(.newData)
}
}NSPersistentCloudKitContainer doesn't need this subscription set up manually — it's already wired inside the framework. Manual setup only matters where you work with CKSyncEngine directly.
Gotchas, limits and a production checklist#
Things no tutorial mentions, but which cost me real debugging hours:
- CloudKit Dashboard isn't a place for live migrations. Changing a record schema in production without first deploying to the Development environment and promoting it reliably breaks compatibility on devices that haven't updated yet.
- Rate limits fail silently. CloudKit doesn't throw a dramatic error when you exceed request quotas — operations sometimes just get deferred and retried later. If your app logic expects an instant response, this looks like a "stuck" sync.
- The Simulator lies about offline mode. Real network-drop scenarios and the resulting merge conflicts need to be tested on physical devices with Wi-Fi actually off, not through Simulator debug toggles.
- The iCloud account may not be signed in, or may be in a restricted mode (Family Sharing, managed Apple ID). The app has to degrade gracefully — work locally and show a clear status, not crash.
- The first full sync after a reinstall is the heaviest scenario in terms of traffic and time; I test it deliberately, rather than relying on incremental scenarios where everything runs smoothly.
Checklist before shipping a CloudKit sync feature:
- Core Data schema is compatible with CloudKit's constraints (optional attributes, no unique constraints)
- Critical entities have an explicit conflict resolution strategy, not the default property trump
-
CKDatabaseSubscriptionis configured and tested (for manualCKSyncEngine) - Tested: two devices offline at the same time make conflicting edits
- Tested: the account isn't signed into iCloud
- Tested: a full sync from scratch
- Logs don't treat silent retries as fatal errors
Bottom line. CloudKit isn't a universal answer to "how do I sync data," but for an app that lives entirely inside the Apple ecosystem and handles a user's personal data, it's a justified replacement for a custom backend: less infrastructure, built-in authentication, and data that physically stays in the user's iCloud instead of on my servers. The cost is having to understand the difference between the convenient but blunt NSPersistentCloudKitContainer and the precise but verbose CKSyncEngine, and picking the right tool per entity rather than for the whole project at once.



