How would you design multi-device sync: identity, change tracking, conflict resolution, deletions and CloudKit?
Predict first
Two devices’ records are merged by last-writer-wins on a version, where a tombstone is just a record with a deleted flag. What is the merged state?
struct Rec { var id: String; var title: String; var version: Int; var deleted: Bool }
func merge(_ a: [Rec], _ b: [Rec]) -> [Rec] {
var out: [String: Rec] = [:]
for r in a + b {
if let cur = out[r.id], cur.version >= r.version { continue }
out[r.id] = r
}
return out.values.sorted { $0.id < $1.id }
}
let phone = [Rec(id: "1", title: "Milk", version: 2, deleted: false), Rec(id: "2", title: "Eggs", version: 1, deleted: false)]
let tablet = [Rec(id: "1", title: "Milk", version: 3, deleted: true), Rec(id: "3", title: "Tea", version: 1, deleted: false)]
let merged = merge(phone, tablet)
print(merged.map { "\($0.id):\($0.deleted ? "deleted" : $0.title)" })
- ["1:Milk", "2:Eggs", "3:Tea"]
- ["1:deleted", "2:Eggs", "3:Tea"]
- ["2:Eggs", "3:Tea"]
Mental model. Two people editing a shared notebook while one is on a plane. You need page numbers that do not collide (ids), a way to see which pages changed (versions), a rule for when both changed the same page, and a “torn out” marker so a removed page does not reappear.
Sync is replicating state between devices that can change it offline and concurrently. Build it from four ideas. Stable identity: every record has a globally unique id created on the device (a UUID), never a local auto-increment id, so two devices can create records without coordinating. Change tracking: know what changed since the last sync, either with a per-record version or modification time, a monotonically increasing sequence number from the server (a change token or cursor: “give me everything after token 87”), or a local change log (a queue of pending operations, the outbox). Conflict resolution: when two devices changed the same record, a rule decides the outcome. Last-writer-wins (by timestamp) is simple but loses an edit and depends on clocks, which can be wrong or skewed (prefer a server-assigned time or a logical/hybrid clock); field-level merge keeps non-overlapping changes (one device edited the title, the other the notes); operation-based or CRDT structures (counters, sets, text) merge without conflicts by construction; and in some domains you must ask the user. Decide per data type; a shopping cart, a note and a bank balance need different rules.
Deletion needs tombstones. If device A deletes a record and device B has it, B cannot tell “A deleted it” from “A never had it”, so it would re-create it. Keep a tombstone (the id plus a deleted flag and version) until all devices have seen it, and let the newer of a delete and an edit win by version. Other design points: make operations idempotent (applying a change twice is harmless, since retries happen), apply remote changes in order and in transactions, batch with pagination, handle partial failure and resume from the last committed token, sync in the background with push to trigger (silent notifications) and handle the schema version of the data from newer or older app versions. CloudKit gives you much of this: private databases per iCloud user, CKRecords with change tags, zones and server change tokens, subscriptions for push, and CKSyncEngine (newer SDKs), which manages the change token, scheduling and retries so you provide the local state and conflict handling; NSPersistentCloudKitContainer mirrors Core Data or SwiftData to CloudKit with limited control and the additive-schema constraint. Otherwise use your own server with the same principles. Test with two devices going offline, editing, and reconnecting in every order, and log conflicts.
The tablet’s newer version of record 1 is a tombstone, so it wins and the record stays deleted. Without the tombstone, the phone’s copy would bring it back.
Common traps
- Using local auto-increment ids. Two devices create the same id for different records; use UUIDs.
- Hard-deleting records locally. The deletion cannot propagate and the record returns from another device; keep a tombstone.
They might follow up with
- Why not trust device timestamps? Clocks differ and can be wrong, so last-writer-wins by device time loses the wrong edit; use server time or a logical clock.
- What does
CKSyncEnginetake off your hands? Change tokens, scheduling, batching and retry, leaving local state and conflicts to you.