Note: When a user changes an iPhone, the app's data should appear on the new device automatically. Otherwise, you lose the customer. We integrate iCloud synchronization via three mechanisms: NSUbiquitousKeyValueStore, CloudKit, and iCloud Documents (UIDocument). Each solves its own task, but for complex synchronization with minimal latency, CloudKit is the best choice. Syncing data via iCloud is not just file transfer—it's a complex process with deltas, conflicts, and storage limitations. Without proper architecture, the user loses progress, settings, or notes when switching devices.
One of our projects is a note-taking app with multi-user editing. The first version uploaded all notes in full at every launch. Traffic exceeded 10 MB per user per day. Switching to delta synchronization with serverChangeToken reduced transferred data to 1 MB—a 90% reduction. Users stopped complaining about slow loading, and the number of requests to CloudKit decreased tenfold.
When to use NSUbiquitousKeyValueStore, CloudKit, or iCloud Documents?
| Criterion | NSUbiquitousKeyValueStore | CloudKit | iCloud Documents |
|---|---|---|---|
| Maximum volume | 1 MB, 1024 keys | 10 MB/user (free), additional 1 GB for $0.99/month | Limited by iCloud free space |
| Data type | Settings, simple configurations | Arbitrary records (notes, progress, lists) | Files, images, documents |
| Synchronization | Automatic, no code | Requires subscriptions and delta updates | Automatic via UIDocument |
| Conflict support | Last write wins | Manual merge for custom zones | Versioning (UIDocument) |
Apple provides 10 MB free CloudKit storage per user, additional 1 GB costs $0.99 per month. Traffic savings from delta sync can reduce query costs by 5 times.
NSUbiquitousKeyValueStore
The simplest option—for small configuration data. Limit: 1 MB total storage, 1024 keys, up to 256 KB per key. Synced automatically, no sync code required.
let store = NSUbiquitousKeyValueStore.default
// Write
store.set(userId, forKey: "lastUserId")
store.set(["theme": "dark", "fontSize": 16], forKey: "userSettings")
store.synchronize() // requests immediate sync, not guaranteed
// 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) }
}
Ideal for settings. For game progress, notes, files—CloudKit.
Why choose CloudKit for complex synchronization?
CloudKit is a full-fledged database in iCloud. Three storage types:
| Database type | Visibility | Quota consumption | Example usage |
|---|---|---|---|
| Private Database | Only the user | User's | Personal notes, settings |
| Public Database | All users | Developer's | App content, ratings |
| Shared Database | Selected users | User's | Collaborative lists, editing |
import CloudKit
class CloudKitManager {
let container = CKContainer(identifier: "iCloud.com.company.appname")
var privateDB: CKDatabase { container.privateCloudDatabase }
// Save a 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)")
}
// Fetch 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
)
}
}
}
How to set up delta synchronization: step-by-step guide
-
Create a subscription for record changes (CKQuerySubscription) — this enables silent push on every change.
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) } -
Implement push notification handling in AppDelegate — on receiving a silent push, call fetchChanges().
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 } -
Use CKFetchRecordZoneChangesOperation with serverChangeToken — load only changed records.
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) }
How to avoid conflicts during simultaneous editing?
CloudKit does not resolve conflicts automatically for Custom Zones. When saving to an existing recordID, if the recordChangeTag does not match, you get a serverRecordChanged error. Manual merge is required. We use a last-writer-wins strategy or three-way merge. Here's a conflict handler:
// In the save block of CKModifyRecordsOperation
operation.perRecordSaveBlock = { recordID, saveResult in
if case .failure(let error) = saveResult {
if let ckError = error as? CKError, ckError.code == .serverRecordChanged {
let serverRecord = ckError.userInfo[CKRecordChangedErrorServerRecordKey] as! CKRecord
let clientRecord = ckError.userInfo[CKRecordChangedErrorClientRecordKey] as! CKRecord
// Resolve conflict: take the latest version
serverRecord["modifiedAt"] = Date()
operation.recordsToSave = [serverRecord]
}
}
}
Common CloudKit sync issues
- CKError.accountTemporarilyUnavailable: user signed out of iCloud or disabled sync for the app. Handle gracefully — don't crash, suggest log in or work locally.
- Network quota exceeded: too frequent requests to CloudKit. Use subscriptions + delta sync instead of polling.
- Conflicts during simultaneous editing. Resolve with manual merge as described above.
What's included in the work
| Stage | Duration | Result |
|---|---|---|
| Requirements analysis and architecture selection | 1–2 days | Technical specification, data schema |
| CloudKit database design | 1–2 days | Record models, indexes, subscriptions |
| Sync implementation (iOS) | 5–10 days | Code with CloudKit integration, conflict handling |
| Push notification setup | 1 day | Silent push for background sync |
| Testing on multiple devices | 2–3 days | Load testing, bug fixes |
| Deployment and monitoring | 1 day | CloudKit console access, error dashboard |
Timeline: from 2 to 4 weeks. Cost is calculated individually. Get a consultation — we'll evaluate your project in one day. Contact us to discuss details.
We have been doing iOS development for over 10 years, implemented 30+ projects with CloudKit sync. We guarantee seamless synchronization between devices within 24 hours after deployment. Order CloudKit integration today and get rid of sync issues.
Apple CloudKit Documentation







