Skillshub axiom-cloud-sync
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
git clone https://github.com/ComeOnOliver/skillshub
T=$(mktemp -d) && git clone --depth=1 https://github.com/ComeOnOliver/skillshub "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/CharlesWiltgen/Axiom/axiom-cloud-sync" ~/.claude/skills/comeonoliver-skillshub-axiom-cloud-sync && rm -rf "$T"
skills/CharlesWiltgen/Axiom/axiom-cloud-sync/SKILL.mdCloud 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.
// ✅ CORRECT: Offline-first architecture class OfflineFirstSync { private let localStore: LocalDatabase // GRDB, SwiftData, Core Data private let syncEngine: CKSyncEngine // Write to LOCAL first, sync to cloud in background func save(_ item: Item) async throws { // 1. Save locally (instant) try await localStore.save(item) // 2. Queue for sync (non-blocking) syncEngine.state.add(pendingRecordZoneChanges: [ .saveRecord(item.recordID) ]) } // Read from LOCAL (instant) func fetch() async throws -> [Item] { return try await localStore.fetchAll() } } // ❌ WRONG: Cloud-first (blocks on network) func save(_ item: Item) async throws { // Fails when offline, slow on bad network 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)
// Server always has latest, client accepts it func resolveConflict(local: CKRecord, server: CKRecord) -> CKRecord { return server // Accept server version }
Use when: Data is non-critical, user won't notice overwrites
Strategy 2: Merge (Most Common)
// Combine changes from both versions func resolveConflict(local: CKRecord, server: CKRecord) -> CKRecord { let merged = server.copy() as! CKRecord // For each field, apply custom merge logic 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
// Present conflict to user 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 // Automatic CloudKit sync with zero configuration @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() } } // Container automatically syncs if CloudKit entitlement present 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)
// For GRDB, SQLite, or custom databases class MySyncManager: CKSyncEngineDelegate { private let engine: CKSyncEngine private let database: GRDBDatabase func handleEvent(_ event: CKSyncEngine.Event) async { switch event { case .stateUpdate(let update): // Persist sync state await saveSyncState(update.stateSerialization) case .fetchedDatabaseChanges(let changes): // Apply changes to local DB for zone in changes.modifications { await handleZoneChanges(zone) } case .sentRecordZoneChanges(let sent): // Mark records as synced 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 } } // Save to iCloud Drive (visible in Files app) 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
// ❌ WRONG: No awareness of pending changes var items: [Item] = [] // Are these synced? Pending? Conflicted? // ✅ CORRECT: Track sync state struct SyncableItem { let item: Item let syncState: SyncState // .synced, .pending, .conflict }
2. Blocking UI on Sync
// ❌ WRONG: UI blocks until sync completes func viewDidLoad() async { items = try await cloudKit.fetchAll() // Spinner forever on airplane tableView.reloadData() } // ✅ CORRECT: Show local data immediately func viewDidLoad() { items = localStore.fetchAll() // Instant tableView.reloadData() Task { await syncEngine.fetchChanges() // Background update } }
3. No Retry Logic
// ❌ WRONG: Single attempt try await cloudKit.save(record) // ✅ CORRECT: Exponential backoff 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 // ✓ (checkmark) case pending // ↻ (arrows) case conflict // ⚠ (warning) case offline // ☁ with X } // In SwiftUI 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
// ❌ WRONG: Fetch everything at once let allRecords = try await database.fetchAll() syncEngine.state.add(pendingRecordZoneChanges: allRecords.map { .saveRecord($0.recordID) }) // ✅ CORRECT: Batch initial sync 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 ) // Process batch 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
— Complete CloudKit API referenceaxiom-cloudkit-ref
— File-based sync with NSFileCoordinatoraxiom-icloud-drive-ref
— Debugging sync failuresaxiom-cloud-sync-diag
— Choosing where to store data locallyaxiom-storage