| name | 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 |
| skill_type | discipline |
| version | 1.0.0 |
| last_updated | Thu Dec 25 2025 00:00:00 GMT+0000 (Coordinated Universal Time) |
| apple_platforms | iOS 10+, macOS 10.12+ |
| related | cloudkit-ref, icloud-drive-ref, cloud-sync-diag, storage |
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.
// ✅ 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 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 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)
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
cloudkit-ref— Complete CloudKit API referenceicloud-drive-ref— File-based sync with NSFileCoordinatorcloud-sync-diag— Debugging sync failuresstorage— Choosing where to store data locally