Nib Nib ← All posts

Your CloudKit sync isn't broken, it just never told anyone

For a while, edits made on my iPhone would only show up on the Mac after I quit and reopened the app. Sync looked broken. Sync was working the entire time — it just had two different ways of not telling anyone, and I had both.

Yuki · 17 July 2026

The symptom that describes two bugs

Type a note on the iPhone, look at the Mac, nothing. Quit the Mac app, reopen it, and there it is — the edit was in the store the whole time.

That's a very unhelpful symptom, because it is exactly what you'd see whether the data never arrived or arrived and stayed invisible. I had one of each, stacked, which is a good way to spend an afternoon fixing the first one and concluding it didn't work.

Worth saying up front what wasn't wrong: not the schema, not the entitlement, not the container. cloudKitDatabase: .automatic on the ModelConfiguration is genuinely all the setup SwiftData asks for, and the mirroring really does work. Neither failure produced an error, a log line, or a nil anywhere I was looking.

macOS won't deliver CloudKit's push unless you ask for it

CloudKit's mirroring gets its liveness from silent push notifications: another device writes, APNs pokes your app, NSPersistentCloudKitContainer runs an import. That's the mechanism that makes sync feel instantaneous rather than eventual.

On macOS, those pushes are not delivered to an app that hasn't explicitly registered for remote notifications. And SwiftData doesn't do it for you — cloudKitDatabase: .automatic sets up the mirroring, not the notification registration. So the app sits there, correctly configured, receiving nothing, until something else triggers an import. Like a launch.

The fix is one line in an app delegate:

func applicationDidFinishLaunching(_ notification: Notification) {
    NSApp.registerForRemoteNotifications()
    …
}

There's a wrinkle in getting there, which is that a pure SwiftUI app doesn't obviously have an app delegate to put this in — you add one with @NSApplicationDelegateAdaptor. On iOS you can go a long way without ever thinking about this, because so much else in an iOS app's life touches push that the registration tends to happen. On the Mac, nothing else asks, so nothing registers, and nothing tells you.

No permission prompt is involved. This is a silent push — you're not asking to show the user anything, so there's no UNUserNotificationCenter authorization dance. It just has to be requested. That's part of why it's easy to miss: it doesn't look like the kind of thing that would need asking for.

An import writes to your store and tells your UI nothing

With push arriving, the import runs. The data is in the store. The list on screen still doesn't move.

If your views are driven by @Query, you never see this one — SwiftData wires the refresh up for you. But the moment you keep your own array of models in an observable store object, you own the refresh, and the mirroring import is not something you did. Nobody calls your code. The rows on screen are from the fetch you ran at launch, and they'll stay that way until you fetch again.

Two notifications tell you an import happened, and I observe both:

for name in [Notification.Name.NSPersistentStoreRemoteChange,
             NSPersistentCloudKitContainer.eventChangedNotification] {
    NotificationCenter.default.addObserver(forName: name, object: nil, queue: nil) { _ in
        Task { @MainActor in
            NotesStore.current?.scheduleRemoteReload()
        }
    }
}

They fire in bursts — an import of any size produces a stream of them — so a debounce isn't a nicety. I coalesce on a one-second timer and re-fetch once.

The part I found genuinely funny, in a bleak way: there is no API to ask CloudKit to sync now. Pull-to-refresh, that most honest of gestures, has nothing to call:

/// Pull-to-refresh. There's no "sync now" API, so give any
/// in-flight import a beat and re-read the store.
func refreshFromRemote() async {
    try? await Task.sleep(for: .seconds(1))
    reloadFromStore()
}

Sleep for a beat, read the store again. That's the whole implementation. It's a placebo with a real effect, because by the time a user pulls to refresh, the import they're waiting for is usually already running — the second fetch just picks it up.

The simulator will lie to you while you debug this

One more from the same neighbourhood, and it's the nastiest: in the simulator, constructing a CloudKit-backed container succeeds without the entitlement. Then every save fails, silently.

This defeats the obvious defensive pattern. You wrap the CloudKit container in try? and fall back to a local store if it throws — exactly right for unsigned dev builds. Except in the simulator the throw never comes, so the fallback never runs, and your writes go nowhere:

// The simulator is excluded: container creation succeeds there even
// without the entitlement, and then every save fails silently.
#if targetEnvironment(simulator)
let cloudAttempt: ModelContainer? = nil
#else
let cloudAttempt = try? ModelContainer(
    for: schema,
    configurations: [ModelConfiguration(schema: schema, cloudKitDatabase: .automatic)]
)
#endif

Success-then-fail is a worse contract than failure. A constructor that throws tells you something. A constructor that returns a broken object tells you nothing until later, somewhere else, in a way that looks like a different bug.

What I'd tell myself

All three failures here have the same shape: the failure is the absence of something, and absence has no error code. No push arrives. No notification fires. No exception throws. Every individual piece reports success, because from its own point of view it succeeded — the container was created, the import completed, the fetch returned what was in the store at the time.

So the debugging move that finally worked wasn't reading my own code harder. It was checking whether the data was in the store at all, independently of the UI. That splits "it never arrived" from "it arrived and nobody looked" — which are the same symptom, completely different bugs, and I'd been treating them as one for an afternoon.

Written while building Nib — a monochrome notepad for Mac, iPhone and iPad where you press record inside a note and your words become searchable text as you speak, transcribed on-device. Transcription and search are free with no monthly cap.

See Nib