| name | axiom-cloud-sync |
| description | Use when choosing between CloudKit vs iCloud Drive, implementing reliable sync, handling offline-first patterns, or designing sync architecture - prevents common sync mistakes that cause data loss |
| license | MIT |
| compatibility | iOS 10+, macOS 10.12+ |
| metadata | {"version":"1.0.0","last-updated":"2025-12-25"} |
Cloud Sync
Overview
Core principle: Choose the right sync technology for the data shape, then implement offline-first patterns that handle network failures gracefully.
Two fundamentally different sync approaches:
- CloudKit — Structured data (records with fields and relationships)
- iCloud Drive — File-based data (documents, images, any file format)
Quick Decision Tree
What needs syncing?
├─ Structured data (records, relationships)?
│ ├─ Using SwiftData? → SwiftData + CloudKit (easiest, iOS 17+)
│ ├─ Need shared/public database? → CKSyncEngine or raw CloudKit
│ └─ Custom persistence (GRDB, SQLite)? → CKSyncEngine (iOS 17+)
│
├─ Documents/files users expect in Files app?
│ └─ iCloud Drive (UIDocument or FileManager)
│
├─ Large binary blobs (images, videos)?
│ ├─ Associated with structured data? → CKAsset in CloudKit
│ └─ Standalone files? → iCloud Drive
│
└─ App settings/preferences?
└─ NSUbiquitousKeyValueStore (simple key-value, 1MB limit)
CloudKit vs iCloud Drive
| Aspect | CloudKit | iCloud Drive |
|---|
| Data shape | Structured records | Files/documents |
| Query support | Full query language | Filename only |
| Relationships | Native support | None (manual) |
| Conflict resolution | Record-level | File-level |
| User visibility | Hidden from user | Visible in Files app |
| Sharing | Record/database sharing | File sharing |
| Offline | Local cache required | Automatic download |
Red Flags
If ANY of these appear, STOP and reconsider:
- ❌ "Store JSON files in CloudKit" — Wrong tool. Use iCloud Drive for files
- ❌ "Build relationships manually in iCloud Drive" — Wrong tool. Use CloudKit
- ❌ "Assume sync is instant" — Network fails. Design offline-first
- ❌ "Skip conflict handling" — Conflicts WILL happen on multiple devices
- ❌ "Use CloudKit for user documents" — Users can't see them. Use iCloud Drive
- ❌ "Sync on app launch only" — Users expect continuous sync
Offline-First Pattern
MANDATORY: All sync code must work offline first.
class OfflineFirstSync {
private let localStore: LocalDatabase
private let syncEngine: CKSyncEngine
func save(_ item: Item) async throws {
try await localStore.save(item)
syncEngine.state.add(pendingRecordZoneChanges: [
.saveRecord(item.recordID)
])
}
func fetch() async throws -> [Item] {
return try await localStore.fetchAll()
}
}
func save(_ item: Item) async throws {
try await cloudKit.save(item)
try await localStore.save(item)
}
Conflict Resolution Strategies
Conflicts occur when two devices edit the same data before syncing.
Strategy 1: Last-Writer-Wins (Simplest)
func resolveConflict(local: CKRecord, server: CKRecord) -> CKRecord {
return server
}
Use when: Data is non-critical, user won't notice overwrites
Strategy 2: Merge (Most Common)
func resolveConflict(local: CKRecord, server: CKRecord) -> CKRecord {
let merged = server.copy() as! CKRecord
merged["notes"] = mergeText(
local["notes"] as? String,
server["notes"] as? String
)
merged["tags"] = mergeSets(
local["tags"] as? [String] ?? [],
server["tags"] as? [String] ?? []
)
return merged
}
Use when: Both versions contain valuable changes
Strategy 3: User Choice
func resolveConflict(local: CKRecord, server: CKRecord) async -> CKRecord {
let choice = await presentConflictUI(local: local, server: server)
return choice == .keepLocal ? local : server
}
Use when: Data is critical, user must decide
Common Patterns
Pattern 1: SwiftData + CloudKit (Recommended for New Apps)
import SwiftData
@Model
class Note {
var title: String
var content: String
var createdAt: Date
init(title: String, content: String) {
self.title = title
self.content = content
self.createdAt = Date()
}
}
let container = try ModelContainer(for: Note.self)
Limitations:
- Private database only (no public/shared)
- Automatic sync (less control over timing)
- No custom conflict resolution
Pattern 2: CKSyncEngine (Custom Persistence)
class MySyncManager: CKSyncEngineDelegate {
private let engine: CKSyncEngine
private let database: GRDBDatabase
func handleEvent(_ event: CKSyncEngine.Event) async {
switch event {
case .stateUpdate(let update):
await saveSyncState(update.stateSerialization)
case .fetchedDatabaseChanges(let changes):
for zone in changes.modifications {
await handleZoneChanges(zone)
}
case .sentRecordZoneChanges(let sent):
for saved in sent.savedRecords {
await markSynced(saved.recordID)
}
}
}
}
See axiom-cloudkit-ref for complete CKSyncEngine setup.
Pattern 3: iCloud Drive Documents
import UIKit
class MyDocument: UIDocument {
var content: Data?
override func contents(forType typeName: String) throws -> Any {
return content ?? Data()
}
override func load(fromContents contents: Any, ofType typeName: String?) throws {
content = contents as? Data
}
}
let url = FileManager.default.url(forUbiquityContainerIdentifier: nil)?
.appendingPathComponent("Documents")
.appendingPathComponent("MyFile.txt")
let doc = MyDocument(fileURL: url!)
doc.content = "Hello".data(using: .utf8)
doc.save(to: url!, for: .forCreating)
See axiom-icloud-drive-ref for NSFileCoordinator and conflict handling.
Anti-Patterns
1. Ignoring Sync State
var items: [Item] = []
struct SyncableItem {
let item: Item
let syncState: SyncState
}
2. Blocking UI on Sync
func viewDidLoad() async {
items = try await cloudKit.fetchAll()
tableView.reloadData()
}
func viewDidLoad() {
items = localStore.fetchAll()
tableView.reloadData()
Task {
await syncEngine.fetchChanges()
}
}
3. No Retry Logic
try await cloudKit.save(record)
func saveWithRetry(_ record: CKRecord, attempts: Int = 3) async throws {
for attempt in 0..<attempts {
do {
try await cloudKit.save(record)
return
} catch let error as CKError where error.isRetryable {
let delay = pow(2.0, Double(attempt))
try await Task.sleep(for: .seconds(delay))
}
}
throw SyncError.maxRetriesExceeded
}
Sync State Indicators
Always show users the sync state:
enum SyncState {
case synced
case pending
case conflict
case offline
}
HStack {
Text(item.title)
Spacer()
SyncIndicator(state: item.syncState)
}
Entitlement Checklist
Before sync will work:
-
Xcode → Signing & Capabilities
- ✓ iCloud capability added
- ✓ CloudKit checked (for CloudKit)
- ✓ iCloud Documents checked (for iCloud Drive)
- ✓ Container selected/created
-
Apple Developer Portal
- ✓ App ID has iCloud capability
- ✓ CloudKit container exists (for CloudKit)
-
Device
- ✓ Signed into iCloud
- ✓ iCloud Drive enabled (Settings → [Name] → iCloud)
Large Dataset Sync
When syncing 10,000+ records, naive approaches cause timeouts and launch slowdowns.
Initial Sync Strategy
let allRecords = try await database.fetchAll()
syncEngine.state.add(pendingRecordZoneChanges: allRecords.map { .saveRecord($0.recordID) })
func performInitialSync(batchSize: Int = 200) async throws {
var cursor: CKQueryOperation.Cursor? = nil
repeat {
let (results, nextCursor) = try await database.records(
matching: query,
resultsLimit: batchSize,
desiredKeys: nil,
continuationCursor: cursor
)
try await localStore.saveBatch(results.compactMap { try? $0.1.get() })
cursor = nextCursor
} while cursor != nil
}
Incremental Sync (After Initial)
CKSyncEngine handles incremental sync automatically — it fetches only changes since the last sync token. Ensure you persist stateSerialization so the engine doesn't re-fetch everything on next launch.
Performance Guidelines
| Dataset Size | Strategy | Notes |
|---|
| < 1,000 records | Default CKSyncEngine | Works out of the box |
| 1,000–10,000 | Batch initial sync | 200-record batches, show progress UI |
| 10,000+ | Pagination + background | Use BGProcessingTask for initial sync |
| 100,000+ | Server-side filtering | Only sync what user needs, lazy-load rest |
Key insight: Initial sync is the bottleneck. After initial sync, CKSyncEngine's incremental approach handles large datasets efficiently because it only fetches deltas.
Pressure Scenarios
Scenario 1: "Just skip conflict handling for v1"
Situation: Deadline pressure to ship without conflict resolution.
Risk: Users WILL edit on multiple devices. Data WILL be lost silently.
Response: "Minimum viable conflict handling takes 2 hours. Silent data loss costs users and generates 1-star reviews."
Scenario 2: "Sync on app launch is enough"
Situation: Avoiding continuous sync complexity.
Risk: Users expect changes to appear within seconds, not on next launch.
Response: Use CKSyncEngine or SwiftData which handle continuous sync automatically.
Related Skills
axiom-cloudkit-ref — Complete CloudKit API reference
axiom-icloud-drive-ref — File-based sync with NSFileCoordinator
axiom-cloud-sync-diag — Debugging sync failures
axiom-storage — Choosing where to store data locally