Thorsten Stark

Avoiding Duplicate Data When SwiftData Syncs Across Devices

Part 2 of a three-part series on SwiftData and iCloud sync. Part 1 covers enabling sync, part 3 covers getting it into production.

In part 1 we enabled CloudKit mirroring and adjusted the model to CloudKit's rules. Everything works on one device, and the data shows up on the second one. This part is about a bug that only exists once there is a second device, and it's easy to ship without noticing.

How the duplicates appear

Most apps seed something on first launch. An inventory app with no battery types is unusable, so Batteries At Home creates AAA, AA, C, D, 9V and the common coin cells on first launch and lets the user add their own from there. A timetable app is just as useless with an empty screen, so KidsPlaner seeds a default set of subjects and a default period schedule. In both cases the user starts from something they can edit instead of a blank grid. That's reasonable, and on a single device it's no problem.

With sync enabled, this happens instead:

  1. The iPhone launches for the first time, seeds 12 subjects, and starts uploading them.
  2. The iPad launches for the first time, finds an empty local store, and seeds its own 12 subjects.
  3. Mirroring completes in both directions. The user now has 24 subjects, two of everything, and no idea why.

The seeding code was never wrong. It just ran twice, once on every device, before either of them knew the other existed. And since @Attribute(.unique) isn't available with CloudKit, nothing upstream deduplicates it for you.

In an inventory app it's worse than cosmetic. Two AA records mean the stock count is split across both of them, so the app reports four batteries in a drawer that holds seven, and every device the user assigns to "AA" points at whichever of the two it happened to find.

Use fixed identifiers instead of random UUIDs

The fix is to make the seeded objects identical on every device, which means their identifiers can't be random. Define them as constants:

enum DefaultSubject: CaseIterable {
    case maths, german, sport

    /// Fixed identifiers so every device seeds the *same* records.
    var id: UUID {
        switch self {
        case .maths:  UUID(uuidString: "1D9A6E4C-0000-4000-8000-000000000001")!
        case .german: UUID(uuidString: "1D9A6E4C-0000-4000-8000-000000000002")!
        case .sport:  UUID(uuidString: "1D9A6E4C-0000-4000-8000-000000000003")!
        }
    }

    var name: String {
        switch self {
        case .maths:  "Maths"
        case .german: "German"
        case .sport:  "Sport"
        }
    }
}

Then seed against those identifiers instead of against a "have I run before?" flag:

extension ModelContext {
    func seedDefaultSubjectsIfNeeded() throws {
        for entry in DefaultSubject.allCases {
            let id = entry.id
            var descriptor = FetchDescriptor<Subject>(predicate: #Predicate { $0.id == id })
            descriptor.fetchLimit = 1

            if try fetch(descriptor).isEmpty {
                insert(Subject(id: id, name: entry.name))
            }
        }
        try save()
    }
}

Note the let id = entry.id before the predicate: #Predicate needs a local value it can capture, not a property access on entry.

This doesn't stop CloudKit from ending up with two records when both devices seed while offline. What it does is make those two records carry the same id, which is what makes the problem solvable: a de-duplication pass can merge them without guessing which one is which, and any relationship pointing at "Maths" points at a Maths that still exists after the merge.

There is a second benefit that shows up later. Once a device has synced, seedDefaultSubjectsIfNeeded() finds the existing records and inserts nothing. The same method is safe to call on every launch, so you don't need a separate migration path for users who already have data.

Duplicates the user creates

For objects the user creates, deterministic identifiers aren't available. Two devices offline, both adding a subject called "Chemistry" or a custom battery type with the same name, produce two genuinely different records, and there is no trick to prevent it.

That leaves two options, and which one is right depends on how visible the duplicate is and what hangs off it:

  • Accept it. Two identical schedule entries next to each other are obvious to the user and one tap to delete. Merge logic for that case is more likely to introduce a bug than the duplicate is to cause trouble.
  • Merge it. Two identical entries in a picker — subjects, battery types — are confusing and don't look like something the user did. Anything that other records point at belongs in this group too, because deleting the wrong one takes those relationships with it. Those are worth a de-duplication pass that merges by a natural key, the name normalized, after a sync lands: repoint the relationships to the surviving record, add up whatever counters were split between them, then delete the other.

Decide this per model type instead of applying one answer to everything.

Conclusion

Duplicate data is not a bug in the mirroring. It is what happens when two devices both do the correct thing before either of them knows about the other. Stable identifiers for anything you seed cost nothing to write on day one and prevent every duplicate that comes from seeding. For user-created data, decide per model whether merging is worth the code, and leave it alone where it isn't.

The last part covers what changes once real users are involved: empty states, widgets, schema deployment and debugging.

Happy coding!

Tagged with: