Implementing Data Synchronization via iCloud
iCloud — Apple's built-in sync platform, accessible without third-party registration. iOS user expects app to remember their data after phone switch and sync between iPhone and iPad. Developer's task — choose right mechanism from three available: NSUbiquitousKeyValueStore, CloudKit and iCloud Documents (via UIDocument).
NSUbiquitousKeyValueStore
Simplest option — for small configuration data. Limit 1 MB total storage, 1024 keys, up to 256 KB per key. Syncs automatically, no sync code.
let store = NSUbiquitousKeyValueStore.default
// Write
store.set(userId, forKey: "lastUserId")
store.set(["theme": "dark", "fontSize": 16], forKey: "userSettings")
store.synchronize() // requests immediate sync, doesn't guarantee
// Read
let theme = store.string(forKey: "userSettings.theme") ?? "light"
// Subscribe to changes from other devices
NotificationCenter.default.addObserver(
self,
selector: #selector(iCloudDidChange),
name: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
object: NSUbiquitousKeyValueStore.default
)
@objc func iCloudDidChange(_ notification: Notification) {
guard let keys = notification.userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String]
else { return }
// Update local state for changed keys
keys.forEach { updateLocalState(forKey: $0) }
}
For syncing settings, small user data — ideal. For game progress, notes, files — CloudKit.
CloudKit: Public, Private, Shared Database
CloudKit — full database in iCloud. Three storage types:
- Private Database — user data, visible only to them. Consumes user's iCloud quota, not yours.
- Public Database — app data, accessible to all. Consumes your developer quota.
- Shared Database — for "share with user", collaborative editing features.
import CloudKit
class CloudKitManager {
let container = CKContainer(identifier: "iCloud.com.company.appname")
var privateDB: CKDatabase { container.privateCloudDatabase }
// Save note
func saveNote(_ note: Note) async throws {
let record = CKRecord(recordType: "Note",
recordID: CKRecord.ID(recordName: note.id))
record["title"] = note.title as CKRecordValue
record["content"] = note.content as CKRecordValue
record["modifiedAt"] = Date() as CKRecordValue
record["isPinned"] = note.isPinned as CKRecordValue
let savedRecord = try await privateDB.save(record)
print("Saved: \(savedRecord.recordID.recordName)")
}
// Load all notes
func fetchAllNotes() async throws -> [Note] {
let predicate = NSPredicate(value: true)
let query = CKQuery(recordType: "Note", predicate: predicate)
query.sortDescriptors = [NSSortDescriptor(key: "modifiedAt", ascending: false)]
let (results, _) = try await privateDB.records(matching: query)
return results.compactMap { (_, result) in
guard let record = try? result.get() else { return nil }
return Note(
id: record.recordID.recordName,
title: record["title"] as? String ?? "",
content: record["content"] as? String ?? "",
isPinned: record["isPinned"] as? Bool ?? false
)
}
}
}
CKSubscription: Push Notifications on Change
When user changes data on iPad, iPhone should know immediately. CKQuerySubscription subscribes to record changes and sends silent push:
func setupSubscription() async throws {
let predicate = NSPredicate(value: true)
let subscription = CKQuerySubscription(
recordType: "Note",
predicate: predicate,
subscriptionID: "notes-changes",
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
)
let notificationInfo = CKSubscription.NotificationInfo()
notificationInfo.shouldSendContentAvailable = true // silent push
subscription.notificationInfo = notificationInfo
try await privateDB.save(subscription)
}
// In AppDelegate / UNUserNotificationCenterDelegate
func application(_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any]) async -> UIBackgroundFetchResult {
let notification = CKNotification(fromRemoteNotificationDictionary: userInfo)
if notification?.containerIdentifier == "iCloud.com.company.appname" {
await cloudKitManager.fetchChanges()
return .newData
}
return .noData
}
Silent push wakes app in background (if Background App Refresh allowed) and app pulls changes.
CKFetchRecordZoneChangesOperation: Efficient Delta Sync
Requesting all records on each sync — inefficient. CKFetchRecordZoneChangesOperation returns only changes since last sync via serverChangeToken:
func fetchChanges() async throws {
let zone = CKRecordZone(zoneName: "NotesZone")
var config = CKFetchRecordZoneChangesOperation.ZoneConfiguration()
config.previousServerChangeToken = UserDefaults.standard
.data(forKey: "notesZoneChangeToken")
.flatMap { try? NSKeyedUnarchiver.unarchivedObject(ofClass: CKServerChangeToken.self, from: $0) }
let operation = CKFetchRecordZoneChangesOperation(
recordZoneIDs: [zone.zoneID],
configurationsByRecordZoneID: [zone.zoneID: config]
)
operation.recordWasChangedBlock = { _, result in
guard let record = try? result.get() else { return }
Task { await self.localStore.upsert(record) }
}
operation.recordWithIDWasDeletedBlock = { recordID, _ in
Task { await self.localStore.delete(id: recordID.recordName) }
}
operation.recordZoneFetchResultBlock = { _, result in
guard case .success(let info) = result else { return }
// Save token for next delta sync
if let tokenData = try? NSKeyedArchiver.archivedData(
withRootObject: info.newServerChangeToken, requiringSecureCoding: true) {
UserDefaults.standard.set(tokenData, forKey: "notesZoneChangeToken")
}
}
privateDB.add(operation)
}
Without serverChangeToken you download everything. With token — only delta.
Typical Problems
CKError.accountTemporarilyUnavailable. User signed out of iCloud or disabled sync for app. Need handling — don't crash, offer to sign in or work locally.
Network quota exceeded. Too frequent CloudKit requests. Apple limits request rate. Use subscriptions + delta sync instead of polling.
Conflicts on simultaneous editing. CloudKit doesn't auto-resolve for Custom Zones. On save to existing recordID, if recordChangeTag doesn't match — error serverRecordChanged. Need manual merge.
Implementing CloudKit sync with CKSubscription, delta sync and conflict handling: 2–4 weeks. Cost calculated individually.







