How I Handle CloudKit Sync Conflicts in SwiftData Apps

I ship five iOS apps that sync data through CloudKit via SwiftData. Fescue syncs invoices and client records. TechSheet syncs vehicle maintenance logs. Mati syncs user preferences and hidden calendar events. None of them run a backend server. None of them have sync-related support tickets.

That’s the pitch for CloudKit + SwiftData: it just works. And it mostly does. But “mostly” hides some real behavior that’s worth understanding before you ship.

The setup is simple

Enabling CloudKit sync in SwiftData is a few lines. Here’s what it looks like in TechSheet:

let modelConfiguration = ModelConfiguration(
    schema: schema,
    isStoredInMemoryOnly: false,
    cloudKitDatabase: .automatic
)

modelContainer = try ModelContainer(
    for: schema,
    configurations: [modelConfiguration]
)

Set cloudKitDatabase to .automatic, add the CloudKit entitlement, and Apple handles sync, storage, and conflict resolution. Your infrastructure cost is zero. You don’t run a server, manage a database, or think about auth. For a solo developer shipping apps with limited time, this tradeoff is everything.

How conflicts actually work

Under the hood, SwiftData’s CloudKit integration uses NSPersistentCloudKitContainer, which applies a last-writer-wins merge policy. When the same record is modified on two devices while both are offline, whichever change reaches the server last overwrites the other. There’s no field-level merge. No conflict callback. No way to intercept individual records and decide what to keep.

This sounds bad until you think about what most indie apps actually store. A maintenance log entry in TechSheet isn’t a collaborative document — one person wrote it on one device. An invoice in Fescue gets created on an iPhone and maybe viewed on an iPad, but it’s not being simultaneously edited in both places. Calendar preferences in Mati are toggled on one device at a time.

For single-user, append-heavy data, last-writer-wins is fine. The conflict scenario that causes data loss — editing the exact same field on two different devices while both are offline, then syncing — is rare in practice for this kind of app.

What you can actually observe

You can’t hook into conflict resolution, but you can monitor sync events. Both TechSheet and Fescue listen for NSPersistentCloudKitContainer.eventChangedNotification to know when sync completes:

.onReceive(
    NotificationCenter.default.publisher(
        for: NSPersistentCloudKitContainer.eventChangedNotification
    )
    .receive(on: DispatchQueue.main)
) { notification in
    guard let event = notification.userInfo?[
        NSPersistentCloudKitContainer.eventNotificationUserInfoKey
    ] as? NSPersistentCloudKitContainer.Event else { return }

    if event.type == .import && event.endDate != nil {
        // Sync finished — check if data arrived
    }
    if let error = event.error {
        // Log it, but don't panic
    }
}

I use this for one specific problem: first-launch sync. When a user installs the app on a new device, their data needs time to arrive from CloudKit. Without monitoring, you’d show onboarding to a user who already has data. Both Fescue and TechSheet wait for the first .import event to complete before deciding whether to show onboarding or the main UI, with a 15-second fallback timeout for devices without iCloud.

Mati takes a different approach for its HiddenEventsStore — it listens for NSPersistentStoreRemoteChange and refreshes its in-memory set of hidden event IDs when remote changes arrive. Simpler, but effective for a flat data set.

Design your schema to avoid conflicts

Since you can’t control conflict resolution, the best strategy is making conflicts unlikely. Patterns I use across my apps:

Soft deletes over hard deletes. Every model in TechSheet has an isDeleted: Bool flag. Queries filter with #Predicate { !$0.isDeleted }. This avoids the nastiest CloudKit conflict: one device deletes a record while another device edits it. With soft deletes, the worst case is a record gets marked as deleted on one device and modified on another — last-writer-wins resolves it, and the data isn’t gone.

Append-only where possible. Maintenance records, invoice line items, and service logs are created once and rarely edited. Each service entry is its own record rather than appending to a mutable array on the parent. This means two devices can add records simultaneously without conflicting — they’re creating separate objects, not modifying the same one.

UUIDs for identity. Every model uses var id: UUID = UUID() assigned at creation time. Two devices creating records offline will never collide on the primary key.

Timestamps for recency. A lastModified: Date field on mutable records gives you a way to reason about which version is newest, even though CloudKit’s last-writer-wins will decide for you.

Where it breaks down

The honest limitations:

  • No custom merge logic. If you need field-level conflict resolution — say, merging two different edits to a notes field — CloudKit + SwiftData won’t do it. One edit will overwrite the other.
  • Deletion conflicts are invisible. If device A deletes a record and device B edits it, you can get orphaned data or unexpected resurrections. Soft deletes help, but they’re a workaround, not a solution.
  • No sync status API. You can observe events, but there’s no way to ask “is this record synced?” or “what’s pending upload?” You’re trusting the system.
  • Sharing is separate. CloudKit sharing (like the team collaboration feature in Fescue) requires dropping down to the CKShare API directly. SwiftData doesn’t abstract sharing for you.

The tradeoff

CloudKit sync is free, invisible to users, and requires no infrastructure. In exchange, you give up fine-grained control over conflict resolution, sync visibility, and data sharing. For the kind of apps I build — single-user tools where data is mostly append-only — that’s a trade I’ll make every time.

If you’re building a collaborative editor or anything where two users modify the same document simultaneously, look elsewhere. But if you’re building a maintenance tracker, a calendar, an invoicing tool, or any app where one person owns their data and syncs it across their devices — CloudKit + SwiftData is the right call.


You might also like