All Articles
CategoryBackend
Reading Time
24 min read
Published
2024-02-10
Word Count
3,416words

Grab a coffee — this one is a deep dive!

iCloud Synchronization with CloudKit

Summary

Data synchronization with CloudKit. Public/private database, subscriptions, conflict resolution, and offline support.

  • CloudKit has 3 database types: Private (user quota), Public (10GB free), Shared.
  • Private database uses the user's iCloud quota, so it costs the developer nothing.
  • By persisting the CKServerChangeToken, incremental sync is performed; this makes a 30s vs 0.5s difference on 10,000+ records.
  • Public DB quota: 10GB storage, 400 requests/sec, 2.5GB/day asset limit (free tier).
iCloud Synchronization with CloudKit

Hello! Today we're going to take a deep dive into CloudKit, Apple's most powerful backend solution. By the end of this guide, you'll be able to build a system that syncs user data flawlessly across all devices.


Table of Contents


🎯 What You'll Learn in This Article

  • CloudKit architecture and database types
  • CKRecord and custom model mapping
  • Real-time sync and subscriptions
  • Conflict resolution strategies
  • Offline-first approach
  • Performance optimization
  • SwiftData + CloudKit integration

📚 What Is CloudKit?

CloudKit is Apple's free framework that provides data storage and synchronization using the iCloud infrastructure.

Database Types

Type
Access
Quota
Use Case
Private
User only
User's iCloud quota
Personal data
Public
Everyone can read
Developer quota
Shared content
Shared
Selected users
Shared quota
Collaborative features
💡 Golden Tip: The private database uses the user's iCloud quota - it costs YOU nothing! For the public database, Apple gives 10GB free per year.

External Resources:


🏗️ CloudKit Setup

1. Project Configuration

swift
1// Xcode > Signing & Capabilities > + Capability > iCloud
2// ✅ Select CloudKit
3// ✅ Create container: iCloud.com.mycompany.myapp
4 
5import CloudKit
6 
7// MARK: - CloudKit Configuration
8enum CloudKitConfig {
9 static let containerIdentifier = "iCloud.com.mycompany.myapp"
10 
11 static var container: CKContainer {
12 CKContainer(identifier: containerIdentifier)
13 }
14 
15 static var privateDatabase: CKDatabase {
16 container.privateCloudDatabase
17 }
18 
19 static var publicDatabase: CKDatabase {
20 container.publicCloudDatabase
21 }
22 
23 static var sharedDatabase: CKDatabase {
24 container.sharedCloudDatabase
25 }
26 
27 // Custom zone for better sync
28 static let customZone = CKRecordZone(zoneName: "MyAppZone")
29 static var customZoneID: CKRecordZone.ID { customZone.zoneID }
30}

2. Account Status Check

swift
1// MARK: - CloudKit Manager
2@MainActor
3final class CloudKitManager: ObservableObject {
4 static let shared = CloudKitManager()
5 
6 @Published private(set) var accountStatus: CKAccountStatus = .couldNotDetermine
7 @Published private(set) var isAvailable = false
8 @Published private(set) var userName: String?
9 @Published private(set) var error: CloudKitError?
10 
11 private let container = CloudKitConfig.container
12 private let privateDB = CloudKitConfig.privateDatabase
13 
14 private init() {
15 Task {
16 await checkAccountStatus()
17 await setupCustomZone()
18 }
19 
20 // Listen for account changes
21 NotificationCenter.default.addObserver(
22 forName: .CKAccountChanged,
23 object: nil,
24 queue: .main
25 ) { [weak self] _ in
26 Task { await self?.checkAccountStatus() }
27 }
28 }
29 
30 // MARK: - Account Status
31 func checkAccountStatus() async {
32 do {
33 accountStatus = try await container.accountStatus()
34 isAvailable = accountStatus == .available
35 
36 if isAvailable {
37 // Get user info
38 let userID = try await container.userRecordID()
39 userName = try? await fetchUserName(userID: userID)
40 }
41 
42 print("☁️ CloudKit status: \(accountStatus)")
43 } catch {
44 self.error = .accountError(error)
45 isAvailable = false
46 }
47 }
48 
49 private func fetchUserName(userID: CKRecord.ID) async throws -> String {
50 let userRecord = try await privateDB.record(for: userID)
51 // User's first name (iOS 15+)
52 if let firstName = userRecord["firstName"] as? String {
53 return firstName
54 }
55 return "User"
56 }
57 
58 // MARK: - Custom Zone Setup
59 func setupCustomZone() async {
60 guard isAvailable else { return }
61 
62 do {
63 // Check if zone exists, otherwise create it
64 let zones = try await privateDB.allRecordZones()
65 
66 if !zones.contains(where: { $0.zoneID == CloudKitConfig.customZoneID }) {
67 let _ = try await privateDB.save(CloudKitConfig.customZone)
68 print("✅ Custom zone created")
69 }
70 } catch {
71 print("❌ Zone setup error: \(error)")
72 }
73 }
74}
75 
76// MARK: - Error Types
77enum CloudKitError: LocalizedError {
78 case accountError(Error)
79 case networkError
80 case quotaExceeded
81 case recordNotFound
82 case conflictError(serverRecord: CKRecord)
83 case permissionError
84 case unknownError(Error)
85 
86 var errorDescription: String? {
87 switch self {
88 case .accountError:
89 return "Your iCloud account is not active."
90 case .networkError:
91 return "Internet connection required."
92 case .quotaExceeded:
93 return "iCloud storage is full."
94 case .recordNotFound:
95 return "Record not found."
96 case .conflictError:
97 return "A sync conflict occurred."
98 case .permissionError:
99 return "Access permission denied."
100 case .unknownError(let error):
101 return error.localizedDescription
102 }
103 }
104}

📦 Model and Record Mapping

3. CloudKit Protocol

swift
1import CloudKit
2 
3// MARK: - CloudKit Record Protocol
4protocol CloudKitRecord: Identifiable, Sendable {
5 static var recordType: String { get }
6 
7 var id: UUID { get }
8 var ckRecordID: CKRecord.ID? { get set }
9 var createdAt: Date { get }
10 var modifiedAt: Date { get set }
11 
12 init?(from record: CKRecord)
13 func toRecord() -> CKRecord
14}
15 
16extension CloudKitRecord {
17 static var recordType: String {
18 String(describing: Self.self)
19 }
20 
21 /// Create a CKRecord.ID (in the custom zone)
22 func makeRecordID() -> CKRecord.ID {
23 if let existing = ckRecordID {
24 return existing
25 }
26 return CKRecord.ID(
27 recordName: id.uuidString,
28 zoneID: CloudKitConfig.customZoneID
29 )
30 }
31}

4. Model Implementation

swift
1// MARK: - Note Model
2struct Note: CloudKitRecord, Equatable, Hashable {
3 let id: UUID
4 var ckRecordID: CKRecord.ID?
5 var title: String
6 var content: String
7 var color: NoteColor
8 var isPinned: Bool
9 var tags: [String]
10 let createdAt: Date
11 var modifiedAt: Date
12 
13 // Local-only properties (not synced)
14 var isSelected: Bool = false
15 
16 enum NoteColor: String, CaseIterable, Codable {
17 case yellow, blue, green, red, purple
18 }
19 
20 // MARK: - Init
21 init(
22 id: UUID = UUID(),
23 title: String,
24 content: String,
25 color: NoteColor = .yellow,
26 isPinned: Bool = false,
27 tags: [String] = []
28 ) {
29 self.id = id
30 self.title = title
31 self.content = content
32 self.color = color
33 self.isPinned = isPinned
34 self.tags = tags
35 self.createdAt = Date()
36 self.modifiedAt = Date()
37 }
38 
39 // MARK: - From CKRecord
40 init?(from record: CKRecord) {
41 guard let title = record["title"] as? String,
42 let content = record["content"] as? String,
43 let colorString = record["color"] as? String,
44 let color = NoteColor(rawValue: colorString),
45 let createdAt = record["createdAt"] as? Date,
46 let modifiedAt = record["modifiedAt"] as? Date,
47 let idString = record["uuid"] as? String,
48 let id = UUID(uuidString: idString) else {
49 return nil
50 }
51 
52 self.id = id
53 self.ckRecordID = record.recordID
54 self.title = title
55 self.content = content
56 self.color = color
57 self.isPinned = record["isPinned"] as? Bool ?? false
58 self.tags = record["tags"] as? [String] ?? []
59 self.createdAt = createdAt
60 self.modifiedAt = modifiedAt
61 }
62 
63 // MARK: - To CKRecord
64 func toRecord() -> CKRecord {
65 let recordID = makeRecordID()
66 let record = CKRecord(recordType: Self.recordType, recordID: recordID)
67 
68 record["uuid"] = id.uuidString
69 record["title"] = title
70 record["content"] = content
71 record["color"] = color.rawValue
72 record["isPinned"] = isPinned
73 record["tags"] = tags
74 record["createdAt"] = createdAt
75 record["modifiedAt"] = modifiedAt
76 
77 return record
78 }
79}
80 
81// 🐣 Easter Egg: Special note title
82// Type "konami" to unlock a hidden color!
83extension Note {
84 mutating func checkEasterEgg() {
85 if title.lowercased() == "konami" {
86 color = .purple
87 title = "🎮 Konami Code Activated!"
88 }
89 }
90}

🔄 CRUD Operations

5. Save, Fetch, Delete

swift
1// MARK: - CloudKit Operations
2extension CloudKitManager {
3 
4 // MARK: - Save
5 func save<T: CloudKitRecord>(_ item: T) async throws -> T {
6 guard isAvailable else {
7 throw CloudKitError.accountError(NSError())
8 }
9 
10 var mutableItem = item
11 mutableItem.modifiedAt = Date()
12 
13 let record = mutableItem.toRecord()
14 
15 do {
16 let savedRecord = try await privateDB.save(record)
17 
18 // Update the CKRecord.ID
19 if var updated = T(from: savedRecord) {
20 updated.ckRecordID = savedRecord.recordID
21 return updated
22 }
23 return mutableItem
24 
25 } catch let error as CKError {
26 throw handleCKError(error)
27 }
28 }
29 
30 // MARK: - Fetch All
31 func fetchAll<T: CloudKitRecord>(
32 ofType type: T.Type,
33 predicate: NSPredicate = NSPredicate(value: true),
34 sortDescriptors: [NSSortDescriptor] = [
35 NSSortDescriptor(key: "modifiedAt", ascending: false)
36 ],
37 limit: Int = 100
38 ) async throws -> [T] {
39 
40 let query = CKQuery(recordType: T.recordType, predicate: predicate)
41 query.sortDescriptors = sortDescriptors
42 
43 var allRecords: [T] = []
44 var cursor: CKQueryOperation.Cursor?
45 
46 repeat {
47 let (records, newCursor) = try await fetchBatch(
48 query: query,
49 cursor: cursor,
50 limit: limit
51 )
52 
53 let items = records.compactMap { T(from: $0) }
54 allRecords.append(contentsOf: items)
55 cursor = newCursor
56 
57 } while cursor != nil
58 
59 return allRecords
60 }
61 
62 private func fetchBatch(
63 query: CKQuery,
64 cursor: CKQueryOperation.Cursor?,
65 limit: Int
66 ) async throws -> ([CKRecord], CKQueryOperation.Cursor?) {
67 
68 if let cursor {
69 // Continue from cursor
70 let (results, newCursor) = try await privateDB.records(
71 continuingMatchFrom: cursor,
72 resultsLimit: limit
73 )
74 let records = results.compactMap { try? $0.1.get() }
75 return (records, newCursor)
76 } else {
77 // Initial query
78 let (results, newCursor) = try await privateDB.records(
79 matching: query,
80 inZoneWith: CloudKitConfig.customZoneID,
81 resultsLimit: limit
82 )
83 let records = results.compactMap { try? $0.1.get() }
84 return (records, newCursor)
85 }
86 }
87 
88 // MARK: - Fetch Single
89 func fetch<T: CloudKitRecord>(
90 _ type: T.Type,
91 id: UUID
92 ) async throws -> T? {
93 
94 let recordID = CKRecord.ID(
95 recordName: id.uuidString,
96 zoneID: CloudKitConfig.customZoneID
97 )
98 
99 do {
100 let record = try await privateDB.record(for: recordID)
101 return T(from: record)
102 } catch let error as CKError where error.code == .unknownItem {
103 return nil
104 }
105 }
106 
107 // MARK: - Delete
108 func delete<T: CloudKitRecord>(_ item: T) async throws {
109 let recordID = item.makeRecordID()
110 try await privateDB.deleteRecord(withID: recordID)
111 }
112 
113 // MARK: - Batch Save
114 func saveAll<T: CloudKitRecord>(_ items: [T]) async throws -> [T] {
115 guard !items.isEmpty else { return [] }
116 
117 let records = items.map { item -> CKRecord in
118 var mutable = item
119 mutable.modifiedAt = Date()
120 return mutable.toRecord()
121 }
122 
123 let operation = CKModifyRecordsOperation(
124 recordsToSave: records,
125 recordIDsToDelete: nil
126 )
127 operation.savePolicy = .changedKeys // Only send changed fields
128 
129 let (savedRecords, _) = try await privateDB.modifyRecords(
130 saving: records,
131 deleting: [],
132 savePolicy: .changedKeys,
133 atomically: true
134 )
135 
136 return savedRecords.compactMap { result -> T? in
137 guard case .success(let record) = result.1 else { return nil }
138 return T(from: record)
139 }
140 }
141 
142 // MARK: - Error Handling
143 private func handleCKError(_ error: CKError) -> CloudKitError {
144 switch error.code {
145 case .networkFailure, .networkUnavailable:
146 return .networkError
147 case .quotaExceeded:
148 return .quotaExceeded
149 case .unknownItem:
150 return .recordNotFound
151 case .serverRecordChanged:
152 if let serverRecord = error.userInfo[CKRecordChangedErrorServerRecordKey] as? CKRecord {
153 return .conflictError(serverRecord: serverRecord)
154 }
155 return .unknownError(error)
156 case .notAuthenticated, .permissionFailure:
157 return .permissionError
158 default:
159 return .unknownError(error)
160 }
161 }
162}

📡 Real-Time Sync and Subscriptions

6. Change Subscriptions

swift
1// MARK: - Subscriptions
2extension CloudKitManager {
3 
4 /// Listen for database changes
5 func setupSubscriptions() async throws {
6 // Clear previous subscriptions
7 let existingSubscriptions = try await privateDB.allSubscriptions()
8 for sub in existingSubscriptions {
9 try await privateDB.deleteSubscription(withID: sub.subscriptionID)
10 }
11 
12 // Zone-based subscription (more efficient)
13 let subscription = CKDatabaseSubscription(subscriptionID: "private-changes")
14 
15 let notificationInfo = CKSubscription.NotificationInfo()
16 notificationInfo.shouldSendContentAvailable = true // Silent push
17 // notificationInfo.alertBody = "Your notes have been updated!" // Visible push
18 subscription.notificationInfo = notificationInfo
19 
20 try await privateDB.save(subscription)
21 print("✅ Subscription created")
22 }
23 
24 /// Notify of changes via push notification
25 func handlePushNotification(userInfo: [AnyHashable: Any]) async {
26 guard let notification = CKNotification(fromRemoteNotificationDictionary: userInfo) else {
27 return
28 }
29 
30 // Start sync for silent push
31 if notification.subscriptionID == "private-changes" {
32 await fetchChanges()
33 }
34 }
35}
36 
37// MARK: - AppDelegate Push Handling
38extension AppDelegate {
39 func application(
40 _ application: UIApplication,
41 didReceiveRemoteNotification userInfo: [AnyHashable: Any],
42 fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
43 ) {
44 Task {
45 await CloudKitManager.shared.handlePushNotification(userInfo: userInfo)
46 completionHandler(.newData)
47 }
48 }
49}

7. Change Token Sync

swift
1// MARK: - Incremental Sync
2extension CloudKitManager {
3 private var changeTokenKey: String { "cloudkit.changeToken" }
4 
5 /// Fetch only changed records (incremental sync)
6 func fetchChanges() async {
7 guard isAvailable else { return }
8 
9 do {
10 // Load previous token
11 let previousToken = loadChangeToken()
12 
13 // Fetch zone changes
14 let config = CKFetchRecordZoneChangesOperation.ZoneConfiguration()
15 config.previousServerChangeToken = previousToken
16 
17 var changedRecords: [CKRecord] = []
18 var deletedRecordIDs: [CKRecord.ID] = []
19 var newToken: CKServerChangeToken?
20 
21 // Modern async API
22 let changes = privateDB.recordZoneChanges(
23 inZoneWith: CloudKitConfig.customZoneID,
24 since: previousToken
25 )
26 
27 for try await change in changes {
28 switch change {
29 case .changed(let record):
30 changedRecords.append(record)
31 print("📥 Changed: \(record.recordType) - \(record.recordID.recordName)")
32 
33 case .deleted(let recordID, let recordType):
34 deletedRecordIDs.append(recordID)
35 print("🗑️ Deleted: \(recordType) - \(recordID.recordName)")
36 }
37 }
38 
39 // Save the token
40 // Note: in iOS 17+, the token is in the changes response
41 if let token = newToken {
42 saveChangeToken(token)
43 }
44 
45 // Update UI (via NotificationCenter or Combine)
46 await MainActor.run {
47 NotificationCenter.default.post(
48 name: .cloudKitDidFetchChanges,
49 object: nil,
50 userInfo: [
51 "changed": changedRecords,
52 "deleted": deletedRecordIDs
53 ]
54 )
55 }
56 
57 } catch {
58 print("❌ Fetch changes error: \(error)")
59 }
60 }
61 
62 // MARK: - Token Persistence
63 private func loadChangeToken() -> CKServerChangeToken? {
64 guard let data = UserDefaults.standard.data(forKey: changeTokenKey) else {
65 return nil
66 }
67 return try? NSKeyedUnarchiver.unarchivedObject(
68 ofClass: CKServerChangeToken.self,
69 from: data
70 )
71 }
72 
73 private func saveChangeToken(_ token: CKServerChangeToken) {
74 guard let data = try? NSKeyedArchiver.archivedData(
75 withRootObject: token,
76 requiringSecureCoding: true
77 ) else { return }
78 
79 UserDefaults.standard.set(data, forKey: changeTokenKey)
80 }
81}
82 
83extension Notification.Name {
84 static let cloudKitDidFetchChanges = Notification.Name("cloudKitDidFetchChanges")
85}

⚔️ Conflict Resolution

8. Merge Strategies

swift
1// MARK: - Conflict Resolution
2enum ConflictResolutionStrategy {
3 case clientWins // Local change wins
4 case serverWins // Server change wins
5 case lastWriteWins // Most recent modifiedAt wins
6 case merge // Field-level merge
7 case askUser // Ask the user
8}
9 
10extension CloudKitManager {
11 
12 /// Conflict-aware save
13 func saveWithConflictResolution<T: CloudKitRecord>(
14 _ item: T,
15 strategy: ConflictResolutionStrategy = .lastWriteWins
16 ) async throws -> T {
17 
18 do {
19 return try await save(item)
20 
21 } catch CloudKitError.conflictError(let serverRecord) {
22 // A conflict occurred - resolve it based on the strategy
23 let resolvedRecord = resolveConflict(
24 local: item.toRecord(),
25 server: serverRecord,
26 strategy: strategy
27 )
28 
29 // Save the resolved record
30 let savedRecord = try await privateDB.save(resolvedRecord)
31 
32 if let resolved = T(from: savedRecord) {
33 return resolved
34 }
35 throw CloudKitError.unknownError(NSError())
36 }
37 }
38 
39 private func resolveConflict(
40 local: CKRecord,
41 server: CKRecord,
42 strategy: ConflictResolutionStrategy
43 ) -> CKRecord {
44 
45 switch strategy {
46 case .clientWins:
47 // Copy local values into the server record
48 for key in local.allKeys() {
49 server[key] = local[key]
50 }
51 return server
52 
53 case .serverWins:
54 // Leave the server record as-is
55 return server
56 
57 case .lastWriteWins:
58 // Compare modifiedAt
59 let localDate = local["modifiedAt"] as? Date ?? .distantPast
60 let serverDate = server["modifiedAt"] as? Date ?? .distantPast
61 
62 if localDate > serverDate {
63 for key in local.allKeys() {
64 server[key] = local[key]
65 }
66 }
67 return server
68 
69 case .merge:
70 // Field-level merge (take the most recent value for each field)
71 return mergeRecords(local: local, server: server)
72 
73 case .askUser:
74 // In this case throw and let the UI handle it
75 return server
76 }
77 }
78 
79 private func mergeRecords(local: CKRecord, server: CKRecord) -> CKRecord {
80 // Field-specific merge logic
81 // Example: title always from local, content always from server
82 
83 let allKeys = Set(local.allKeys() + server.allKeys())
84 
85 for key in allKeys {
86 // Prefer the non-null value
87 if local[key] != nil && server[key] == nil {
88 server[key] = local[key]
89 }
90 // If both exist, pick based on modifiedAt
91 // (This is a simple example, real-world logic can be more complex)
92 }
93 
94 server["modifiedAt"] = Date()
95 return server
96 }
97}

💾 Offline-First Architecture

9. Local Cache + Sync

swift
1import SwiftData
2 
3// MARK: - Offline-First Note Store
4@MainActor
5final class NoteStore: ObservableObject {
6 @Published private(set) var notes: [Note] = []
7 @Published private(set) var syncStatus: SyncStatus = .idle
8 @Published private(set) var pendingChanges: Int = 0
9 
10 private let cloudKit = CloudKitManager.shared
11 private var modelContext: ModelContext?
12 
13 enum SyncStatus {
14 case idle
15 case syncing
16 case error(Error)
17 case success
18 }
19 
20 init() {
21 // SwiftData setup (or Core Data)
22 setupLocalStorage()
23 
24 // Listen for CloudKit changes
25 NotificationCenter.default.addObserver(
26 forName: .cloudKitDidFetchChanges,
27 object: nil,
28 queue: .main
29 ) { [weak self] notification in
30 self?.handleRemoteChanges(notification)
31 }
32 
33 // Initial load
34 Task {
35 await loadFromLocal()
36 await sync()
37 }
38 }
39 
40 // MARK: - Local Storage
41 private func setupLocalStorage() {
42 // SwiftData or Core Data setup
43 }
44 
45 func loadFromLocal() async {
46 // Load from local cache
47 }
48 
49 // MARK: - CRUD (Offline-First)
50 func add(_ note: Note) async {
51 // 1. Save locally first
52 notes.append(note)
53 saveToLocal(note, action: .create)
54 
55 // 2. Sync to CloudKit in the background
56 Task {
57 do {
58 _ = try await cloudKit.save(note)
59 markSynced(note.id)
60 } catch {
61 markPendingSync(note.id)
62 incrementPendingChanges()
63 }
64 }
65 }
66 
67 func update(_ note: Note) async {
68 // 1. Update locally
69 if let index = notes.firstIndex(where: { $0.id == note.id }) {
70 notes[index] = note
71 }
72 saveToLocal(note, action: .update)
73 
74 // 2. CloudKit sync
75 Task {
76 do {
77 _ = try await cloudKit.saveWithConflictResolution(note)
78 markSynced(note.id)
79 } catch {
80 markPendingSync(note.id)
81 }
82 }
83 }
84 
85 func delete(_ note: Note) async {
86 // 1. Delete locally
87 notes.removeAll { $0.id == note.id }
88 saveToLocal(note, action: .delete)
89 
90 // 2. Delete from CloudKit
91 Task {
92 do {
93 try await cloudKit.delete(note)
94 } catch {
95 // Already deleted is fine
96 }
97 }
98 }
99 
100 // MARK: - Sync
101 func sync() async {
102 guard cloudKit.isAvailable else {
103 syncStatus = .error(CloudKitError.accountError(NSError()))
104 return
105 }
106 
107 syncStatus = .syncing
108 
109 do {
110 // 1. Upload pending local changes
111 await uploadPendingChanges()
112 
113 // 2. Fetch remote changes
114 await cloudKit.fetchChanges()
115 
116 syncStatus = .success
117 pendingChanges = 0
118 
119 } catch {
120 syncStatus = .error(error)
121 }
122 }
123 
124 private func uploadPendingChanges() async {
125 // Find pending local changes and upload them
126 let pending = getPendingNotes()
127 
128 for note in pending {
129 do {
130 _ = try await cloudKit.save(note)
131 markSynced(note.id)
132 } catch {
133 // Retry later
134 }
135 }
136 }
137 
138 // MARK: - Remote Changes Handler
139 private func handleRemoteChanges(_ notification: Notification) {
140 guard let userInfo = notification.userInfo,
141 let changed = userInfo["changed"] as? [CKRecord],
142 let deleted = userInfo["deleted"] as? [CKRecord.ID] else {
143 return
144 }
145 
146 // Changed records
147 for record in changed {
148 if let note = Note(from: record) {
149 // Update if it exists locally, otherwise add it
150 if let index = notes.firstIndex(where: { $0.id == note.id }) {
151 notes[index] = note
152 } else {
153 notes.append(note)
154 }
155 saveToLocal(note, action: .update)
156 }
157 }
158 
159 // Deleted records
160 for recordID in deleted {
161 if let uuid = UUID(uuidString: recordID.recordName) {
162 notes.removeAll { $0.id == uuid }
163 deleteFromLocal(uuid)
164 }
165 }
166 }
167 
168 // MARK: - Helpers
169 private func saveToLocal(_ note: Note, action: SyncAction) { }
170 private func deleteFromLocal(_ id: UUID) { }
171 private func markSynced(_ id: UUID) { }
172 private func markPendingSync(_ id: UUID) { }
173 private func incrementPendingChanges() { pendingChanges += 1 }
174 private func getPendingNotes() -> [Note] { [] }
175 
176 enum SyncAction { case create, update, delete }
177}

Reader Reward

🎁 CLOUDKIT PRODUCTION CHEAT SHEET

Quick Reference

Database Types

swift
container.privateCloudDatabase // User's data
container.publicCloudDatabase // Shared data
container.sharedCloudDatabase // Collaboration

Common Operations

swift
// Save
try await database.save(record)
 
// Fetch
try await database.record(for: recordID)
 
// Query
try await database.records(matching: query)
 
// Delete
try await database.deleteRecord(withID: recordID)
 
// Batch
try await database.modifyRecords(saving: [], deleting: [])

Error Handling

swift
case .networkFailure → Retry with exponential backoff
case .quotaExceeded → Inform user
case .serverRecordChanged → Conflict resolution
case .unknownItem → Record deleted remotely
case .notAuthenticated → Request iCloud login

Performance Tips

  • Use a custom zone (for delta sync)
  • Batch operations (50 records max)
  • Incremental sync with change tokens
  • QualityOfService.userInitiated for UI
  • Silent sync via background fetch

Debug Commands

bash
# CloudKit Console
https://icloud.developer.apple.com
 
# Reset development environment
# CloudKit Console > Schema > Reset Environment
 
# View logs
# Console.app > Filter: "CloudKit"

Quotas (Free Tier)

| Resource | Private DB | Public DB | | ------------ | ----------- | --------- | | Storage | User iCloud | 10GB | | Requests/sec | 40 | 400 | | Assets/day | 250MB | 2.5GB |

<a id="cloudkit-pro-tips"></a>

💡 Pro Tips

  1. Use a Custom Zone - Change tokens work more efficiently with a custom zone instead of the default zone.
  1. Batch Operations - Use modifyRecords for batch operations instead of saving one by one.
  1. Set QoS - Use .userInitiated for UI operations, .utility for background sync.
  1. Retry Logic - Apply exponential backoff for network errors.
  1. Asset Optimization - Save large files as CKAsset.
  1. Use References - Use CKRecord.Reference for related data.
  1. Create Indexes - Index frequently queried fields in the CloudKit Console.
  1. Silent Push - Background sync via shouldSendContentAvailable.

📖 Conclusion

CloudKit is a free and powerful backend solution in the Apple ecosystem. Since the private database uses the user's quota, your cost is almost zero!

With SwiftData + CloudKit integration, you don't even need to write sync code anymore - Apple handles everything.

See you in the next article! ☁️


_iCloud sync is great but tricky. If you get stuck, ask on Twitter!_

Easter Egg

You found a hidden gem!

There's a hidden detail in this section. Want to uncover it?

GOLDEN TIP

The most valuable insight in this article

This tip holds the article's most important takeaway.

Tags

#CloudKit#iCloud#iOS#Sync#backend#SwiftData
Muhittin Çamdalı

Muhittin Çamdalı

Lead Mobile Engineer

Lead Mobile Engineer with 12+ years of experience. Expert in iOS, Android and cross-platform architectures with Swift, SwiftUI, Kotlin and Flutter. I build performant, user-friendly mobile apps.

iOS Development News

Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.

We respect your privacy. You can unsubscribe at any time.

Share