When I updated my iOS app to Swift 6, the compiler was happy. All strict concurrency checks passed. But at runtime, iCloud sync stopped working silently.
No errors. No warnings. No crash. The CloudKit observer was firing, and my NSPersistentCloudKitContainer was calling my data sync method, but the updates weren't persisting to the local database.
Debugging took two hours. The issue was a single line in my Core Data sync layer:
@MainActor
class SyncManager: NSObject, NSFetchedResultsControllerDelegate {
func updateLocalDatabase(_ changes: [NSManagedObject]) {
// This method is called by CloudKit observer
// But @MainActor + strict concurrency = deadlock in iCloud callback
}
}
The @MainActor attribute was preventing CloudKit's background thread from delivering updates. Swift 6's actor isolation was doing its job — isolating the main thread — but it didn't know that CloudKit observers need to call back to the main actor without deadlocking.
The fix was one line:
@MainActor
class SyncManager: NSObject, NSFetchedResultsControllerDelegate {
nonisolated func updateLocalDatabase(_ changes: [NSManagedObject]) {
DispatchQueue.main.async {
// Now safe: we explicitly hop to main thread
}
}
}
This article covers why Swift 6's actor isolation broke CloudKit, when you need nonisolated, and how to ship iCloud sync in the strict concurrency era.
Why Swift 6 Actor Isolation Breaks iCloud Callbacks
Swift 6 enforces strict concurrency by default. When you mark a class with @MainActor, all methods are assumed to run on the main thread. The compiler prevents you from calling main-thread methods from background threads.
This is correct behavior for normal code. But CloudKit observers violate this assumption:
// CloudKit calls this from a background thread
func persistentStoreRemoteChange(_ notification: Notification) {
// This is called on NSPrivateQueueConcurrencyType (background)
// But your sync manager is @MainActor
syncManager.updateLocalDatabase(changes) // ← ERROR: main-thread method called from background
}
With Swift 5, this silently worked because actor isolation wasn't enforced. With Swift 6, the compiler flags it as an isolation violation, and the runtime may deadlock trying to enforce it.
The result: iCloud sync silently stops working. The CloudKit observer fires, but the callback never completes, so the database never updates.
The Root Cause: NSPersistentCloudKitContainer's Threading Model
NSPersistentCloudKitContainer was designed before Swift's actor model. It makes assumptions about threading:
- CloudKit change notifications arrive on a private background queue
- Your app's sync code should call back to main thread to modify Core Data
- Core Data's
NSFetchedResultsControllerupdates are delivered on main thread
With Swift 5, you could ignore these details. With Swift 6, you must respect them.
Here's the problematic pattern:
// Swift 5: silent issue, works by luck
@MainActor
class SyncManager: NSObject {
func updateDatabase(_ changes: [NSManagedObject]) {
// Called from background (CloudKit), but we're @MainActor
// Swift 5: works (or hangs silently)
// Swift 6: compile error + runtime deadlock
}
}
The Solution: nonisolated Entry Point
The fix is to make the CloudKit callback method nonisolated, then explicitly hop to the main thread inside it:
import CoreData
import CloudKit
@MainActor
class SyncManager: NSObject, NSFetchedResultsControllerDelegate {
// CloudKit observer calls this from background
// Mark as nonisolated to accept the background call
nonisolated func persistentStoreRemoteChange(_ notification: Notification) {
// We're on a background thread, not isolated to main
// Explicitly dispatch to main thread
DispatchQueue.main.async { [weak self] in
self?.syncFromCloudKit(notification)
}
}
// Now we're back on main thread, can call main-actor methods safely
func syncFromCloudKit(_ notification: Notification) {
let changes = notification.userInfo?[NSUpdatedObjectsKey] as? Set<NSManagedObject> ?? []
self.updateLocalDatabase(changes)
}
@MainActor
func updateLocalDatabase(_ changes: Set<NSManagedObject>) {
// Safely on main thread now
// Core Data update code here
print("Updated \(changes.count) objects")
}
}
Key pattern:
-
nonisolatedmethods can be called from any thread - Inside, use
DispatchQueue.main.asyncto hop to main thread - Call your
@MainActormethods from inside the async block
Real-World Example: NSPersistentCloudKitContainer Setup
import CoreData
class DataController: NSObject {
let container: NSPersistentCloudKitContainer
override init() {
let container = NSPersistentCloudKitContainer(name: "MyApp")
self.container = container
container.loadPersistentStores { _, error in
if let error = error {
print("Core Data load error: \(error)")
}
}
// Register for CloudKit change notifications
NotificationCenter.default.addObserver(
self,
selector: #selector(persistentStoreRemoteChange(_:)),
name: NSPersistentCloudKitContainer.remoteChangeNotification,
object: container.persistentStoreCoordinator
)
}
// This method receives notifications from CloudKit on a background thread
// Swift 6 requires nonisolated to accept the background call
@objc
nonisolated private func persistentStoreRemoteChange(_ notification: Notification) {
print("CloudKit change detected on \(Thread.current.name)")
// Hop to main thread to handle the update
DispatchQueue.main.async { [weak self] in
self?.handleCloudKitChanges(notification)
}
}
@MainActor
private func handleCloudKitChanges(_ notification: Notification) {
// Now safe on main thread
let context = self.container.viewContext
context.mergeChanges(fromRemoteContextSave: notification.userInfo ?? [:])
// Notify UI observers
NotificationCenter.default.post(name: NSNotification.Name("DataDidUpdate"), object: nil)
}
}
When to Use nonisolated
Use nonisolated for:
-
Delegate methods that are called from background:
NSFetchedResultsControllerDelegate,URLSessionDelegate - Notification observers called by system frameworks
- Completion handlers that may be called from background threads
- CloudKit callbacks and other async system APIs
Don't use nonisolated for:
- Regular methods that should stay isolated
- Methods you control the calling context for
- Anything that doesn't have a legitimate cross-thread caller
Testing the Fix
To verify your fix works, add some logging:
@objc
nonisolated private func persistentStoreRemoteChange(_ notification: Notification) {
print("Called on: \(Thread.current.name)") // Should show background thread
DispatchQueue.main.async {
print("Now on: \(Thread.current.name)") // Should show main thread
}
}
Then trigger a CloudKit sync (delete a record on another device, or manually trigger CloudKit).
Broader Pattern: Compat with Old APIs
Swift 6 strict concurrency creates friction with older system APIs that weren't designed with actors in mind:
- CloudKit observers: background threads by design
- URLSession: calls completions on background threads
- Notifications: caller's thread varies
- NSFetchedResultsController: main thread by default, but can be configured
For all of these, the pattern is:
- Mark the entry point as
nonisolated - Explicitly dispatch to the correct isolation level inside
- Call your isolated methods from the dispatcher
Key Takeaways
-
Swift 6 actor isolation breaks CloudKit by design: CloudKit callbacks come from background, but
@MainActorexpects main thread -
nonisolatedis the escape hatch: allows cross-isolation calls, but you must dispatch correctly inside -
Use
DispatchQueue.main.asyncto hop back to main thread: this is the safe pattern - Test with actual CloudKit changes: local code can hide the issue; real sync exposes it
- This pattern applies broadly: any old system API calling back to actor-isolated code needs this treatment
If you're migrating to Swift 6, audit your CloudKit and Network code for similar patterns. The compiler will catch the obvious violations, but subtle deadlocks can hide in notification observers and delegate methods.
Sources
- Swift 6 Concurrency Docs: Actors and Isolation — official guide
- NSPersistentCloudKitContainer Threading Model — Apple's docs (pre-Swift-6)
- CloudKit Change Notification Threading — Apple forum discussion on the exact issue
- Real reproduction from today's shipping — actual code pattern that failed
Subscribe to my Substack for more Swift concurrency patterns and iOS shipping gotchas. Get the TestFlight Bible ($29) for 50+ real shipping workflows. Join the affiliate program and earn 30% recurring on every sale.
Top comments (0)