Building iCloud Sync for a SwiftUI Kanban Board
Why iCloud Sync Is Harder Than It Looks
FenixKanban started as a single-device app. Adding iCloud sync seemed simple — Apple has NSPersistentCloudKitContainer, which promises to handle everything automatically. Just swap out your persistent container and you’re done, right?
Not quite.
The Sync Architecture
NSPersistentCloudKitContainer uses CloudKit’s private database to sync Core Data stores across devices. The good news: it handles conflict resolution, network retries, and background sync automatically. The bad news: “automatically” hides a lot of complexity.
lazy var persistentContainer: NSPersistentCloudKitContainer = {
let container = NSPersistentCloudKitContainer(name: "FenixKanban")
let description = container.persistentStoreDescriptions.first!
description.setOption(true as NSNumber,
forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber,
forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
container.loadPersistentStores { _, error in
if let error { fatalError("Store failed: \(error)") }
}
container.viewContext.automaticallyMergesChangesFromParent = true
return container
}()
Two options matter: NSPersistentHistoryTrackingKey and NSPersistentStoreRemoteChangeNotificationPostOptionKey. Without these, remote changes from other devices arrive silently and your UI doesn’t update.
The Conflict Problem
Kanban boards have a natural conflict scenario: two devices both reorder cards while offline. CloudKit’s last-write-wins semantics don’t map cleanly onto ordered lists.
We store card order as a Double (following the “fractional indexing” pattern — inserting between position 1.0 and 2.0 gives position 1.5). This makes concurrent inserts merge cleanly most of the time. When two devices assign identical fractional positions, we break ties deterministically using the card’s UUID.
extension Card {
func resolveOrderConflict(with other: Card) -> Bool {
// Returns true if self should come first
guard self.position != other.position else {
return self.id.uuidString < other.id.uuidString
}
return self.position < other.position
}
}
Not pretty, but consistent.
Testing Sync
The hardest part of CloudKit development is testing. You can’t mock CloudKit in unit tests easily, and the simulator doesn’t always behave like a real device.
Our approach:
- Two physical devices — an iPhone and an iPad, both logged into the same iCloud account in a development environment
- Airplane mode testing — make changes offline, re-enable connectivity, verify merge
- CloudKit Dashboard — Apple’s web console lets you inspect records directly, invaluable for debugging
We also wrote a small SyncMonitor class that subscribes to NSPersistentStoreRemoteChangeNotification and logs every sync event to a local file. When users report sync issues, we ask them to share that log.
What Actually Works Well
Once you accept the constraints, NSPersistentCloudKitContainer is remarkably solid. Background sync just works. The conflict resolution, while opaque, handles 95% of cases correctly. And users love seeing their boards appear instantly on a new device.
The remaining 5% — edge cases around rapid offline edits, very large boards, and the occasional “ghost card” that appears and disappears — are annoying but rare enough to live with while we track down root causes.
CloudKit sync isn’t magic, but it’s closer to magic than anything else Apple has shipped for cross-device data.