How household sharing works without trusting a server
Two iCloud accounts, one shared budget, no backend I control. NSPersistentCloudKitContainer couldn't do it, so I built the sync engine by hand. Here's what that actually takes.
The Smart Budget lets a household share one budget. Two people, two separate iCloud accounts, both editing the same transactions and budgets in real time. The constraint I set for myself: no server I control in the middle. Your money data syncs directly through Apple's CloudKit, account to account. I never see it, and there's no backend for me to leak, subpoena, or get hacked.
Apple has a framework that looks like it does exactly this — NSPersistentCloudKitContainer with CKShare. I spent three days trying to make it work. Then I deleted it and wrote the sync engine myself. This is the story of why, and what the replacement looks like, with the real code.
The obvious approach, and the crash
The blessed path is: back Core Data with NSPersistentCloudKitContainer (NSPCKC), which mirrors your store to a private CloudKit database automatically, then call share(...) to create a CKShare for the objects you want to share.
It crashes. Not immediately — that's the cruel part. It works in the demo and dies in the messy real case, when your custom CKShare lands in the same private database the framework is busy mirroring. From the comment that now sits where NSPCKC used to be:
/// Apple's `NSPersistentCloudKitContainer` is gone. It crashed when our custom `CKShare`
/// landed in the same private DB it was mirroring (it tries to ingest the share as a
/// `cloudkit.share` Core Data entity that doesn't exist in the model). Replacing it with
/// our own bare-CloudKit pipeline removes the framework collision and gives us one path
/// for all sync, single-user and household.
The framework sees a record of type cloudkit.share in the database it's mirroring, and tries to ingest it as if it were one of your Core Data entities. There is no cloudkit.share entity in your model, because it's a CloudKit system type. Boom.
And the deeper problem wasn't this one crash — it was that I had no way to fix it. From the rewrite plan doc:
Three days of testing
NSPersistentCloudKitContainer+CKSharefor household sharing has produced a recurring pattern: each fix surfaces a new failure mode (zone-not-found wedges, "Can't find metadata" loops, cascade timing producing duplicates, partial-state CKKS resets that can't be recovered). The framework's automatic mirroring is opaque — when it goes wrong we have no API to inspect or correct it; only "purge everything and start over" works.
That's the real indictment. When the automatic thing works, it's magic. When it doesn't, there's no seam to get your hands into. For a feature where "start over" means "delete a family's shared budget," that's disqualifying. The design doc's summary of the trade I made: "trade complexity-of-fighting-the-framework for complexity-of-explicit-control." I'll take explicit control every time it's my users' money.
The replacement: two modes, one engine
I replaced NSPCKC with a bare-CloudKit pipeline: I own the push, the pull, the conflict resolution, and the record translation. Personal single-account sync and household sharing run through the same engine — they differ only by which CloudKit zone and database they point at. The core is an actor:
/// Custom CloudKit sync layer for ALL of the user's shareable data.
/// **Two modes, one engine:**
/// - **Personal mode** (default): syncs `Shared.sqlite` to a `personal-zone` in the
/// user's private CloudKit DB. No `CKShare`.
/// - **Household mode**: syncs to a `household-shared` zone in the owner's private DB,
/// with a `CKShare` attached.
/// **Conflict resolution:** last-writer-wins by `lastModified`.
actor HouseholdSync {
static let shared = HouseholdSync()
The whole "no central server" trick lives in how a participant addresses data. There are four modes, and each picks its zone and database:
enum Mode { case off; case personal; case householdOwner; case householdParticipant }
private var currentZoneID: CKRecordZone.ID? {
switch currentMode {
case .personal:
return CKRecordZone.ID(zoneName: Self.personalZoneName, ownerName: CKCurrentUserDefaultName)
case .householdOwner:
return CKRecordZone.ID(zoneName: Self.householdZoneName, ownerName: CKCurrentUserDefaultName)
case .householdParticipant:
let ownerName = UserDefaults.standard.string(forKey: Self.householdOwnerCKUserIDKey) ?? CKCurrentUserDefaultName
return CKRecordZone.ID(zoneName: Self.householdZoneName, ownerName: ownerName)
default: return nil
}
}
private var currentDatabase: CKDatabase? {
switch currentMode {
case .personal, .householdOwner: return container.privateCloudDatabase
case .householdParticipant: return container.sharedCloudDatabase
default: return nil
}
}
The owner writes to a household-shared zone in their own private database. The participant reads and writes that same zone through their shared database, reconstructing the owner's (zoneName, ownerName) from the owner's CloudKit user ID that got persisted when they accepted the invite. Two private databases, stitched together by one share. Apple's servers do the sync; nobody's server does the logic.
Four pieces feed that engine.
LocalChangeWatcher — turning saves into push ops
A NSManagedObjectContextDidSave observer. Every time the shared store saves, it builds push operations for the inserted/updated/deleted objects and kicks a push. The subtle, load-bearing bit is what it skips:
private func handle(_ notification: Notification) {
guard SyncStatus.shared.isActive else { return }
guard let sharedStore = controller.sharedStore else { return }
// Skip saves emitted by HouseholdSync itself (pull's apply + push's metadata writeback)
if let savingContext = notification.object as? NSManagedObjectContext,
Self.skipContexts.contains(ObjectIdentifier(savingContext)) {
return
}
for object in inserted.union(updated) {
guard object.objectID.persistentStore == sharedStore else { continue }
guard RecordTranslator.shareableEntityNames.contains(object.entity.name ?? "") else { continue }
ops.append(.init(cloudKitRecordName: recordName, entityName: entityName,
kind: .upsert, objectIDURI: object.objectID.uriRepresentation().absoluteString))
}
Task {
await PushQueue.shared.enqueue(ops)
await HouseholdSync.shared.pushPendingChanges()
}
}
That skipContexts set is keyed by ObjectIdentifier, deliberately — reading context.transactionAuthor cross-queue can deadlock Core Data. Without this skip, applying a pulled change would itself trigger a save, which would push it right back, which the partner would pull and re-save… which brings us to the best bug in the project (below).
PushQueue — a durable, self-coalescing FIFO
Network drops. Apps get killed. So the queue of pending pushes is a file-backed actor that flushes to disk atomically on every mutation, and it dedupes on the way in:
func enqueue(_ ops: [Operation]) async {
for op in ops {
// URI-based dedupe FIRST so a stale optimistic-UUID op for the same
// managed object gets dropped.
if let uri = op.objectIDURI {
queue.removeAll { $0.objectIDURI == uri || $0.cloudKitRecordName == op.cloudKitRecordName }
} else {
queue.removeAll { $0.cloudKitRecordName == op.cloudKitRecordName }
}
queue.append(op)
}
await persist()
}
The payload isn't serialized — the push re-reads the managed object's current state at push time. So if you edit a transaction five times in two seconds, those collapse into one push of the final state for free.
RecordTranslator — Core Data ⇄ CKRecord, both directions
Pure functions, no I/O, run inside the caller's context.perform. Every shareable object carries a stable cloudKitRecordName so both devices agree on one identity per logical row. Going out, it encodes only to-one relationships as references, and skips any whose target hasn't been pushed yet:
for (name, rel) in object.entity.relationshipsByName where !rel.isToMany {
guard let target = object.value(forKey: name) as? NSManagedObject else { record[name] = nil; continue }
guard let targetName = target.value(forKey: "cloudKitRecordName") as? String, !targetName.isEmpty else {
continue // target not pushed yet — next cycle fixes it
}
let targetID = CKRecord.ID(recordName: targetName, zoneID: zoneID)
record[name] = CKRecord.Reference(recordID: targetID, action: .none)
}
Coming back in, there's a guard that looks like a nitpick and is actually the whole ballgame:
let target = resolveReference(ref, destinationEntity: rel.destinationEntity!.name!, in: context)
if target != nil {
object.setValue(target, forKey: name)
}
// else: target not pulled to this context yet — leave as-is. Critically we do NOT
// overwrite to nil and create orphan rows that dedupBudgets would group under a
// single nil-category bucket, summing 9 different-category Budgets into one row.
If a transaction arrives before the category it points at, you must not null the relationship. Do that, and you create orphans — which the dedup pass (next) will then happily merge together. Which is exactly how I once produced a single budget row for 6,800 AED out of nine unrelated categories.
The hard parts
HouseholdDedup — converging without a coordinator
When two phones are offline and both create a "Groceries" category, you get duplicates once they sync. There's no server to arbitrate. The fix is convergence: both devices run the identical deterministic pass on the identical data and produce the identical delete set, which then propagates through the normal push pipeline. No coordination needed.
/// **Determinism:** both devices run dedup independently. As long as both pick the same
/// canonical for each group (we use lexicographically-smallest `cloudKitRecordName`,
/// preferring `isSystem` rows for Categories), they produce the same delete set →
/// converges to the same final state under LWW.
Before deleting a duplicate, it repoints every relationship to the survivor and touches lastModified so the merge wins last-writer-wins on the partner's device:
let canonical = pickCanonicalCategory(from: dupes)
for dupe in dupes where dupe.objectID != canonical.objectID {
if let txns = dupe.transactions as? Set<Transaction> {
for txn in txns { txn.category = canonical; txn.lastModified = now }
}
// budgets, splits, aliases, recurringSeries likewise…
context.delete(dupe)
}
The 6,800 AED incident taught me to be paranoid about the grouping key. An early build grouped nil-category budgets under the literal string "nil" and merged nine different categories into one giant row. Now it skips nil-category budgets entirely, and the translator (above) refuses to create the nils in the first place. Two independent guards for one bug, because that bug shows up as a wrong number in someone's budget and there is no worse bug in a finance app than a confidently wrong number.
HouseholdShareAcceptance — the invite hook SwiftUI hides from you
When your partner taps the share link, iOS needs to hand your app the CKShare.Metadata. That happens through a UIWindowSceneDelegate callback — which a pure SwiftUI app doesn't have. So you vend one via UIApplicationDelegateAdaptor:
func windowScene(
_ windowScene: UIWindowScene,
userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata
) {
let metadata = cloudKitShareMetadata
Task {
do { try await HouseholdSync.shared.acceptShare(metadata: metadata) }
catch { /* … */ }
}
}
The same delegate handles silent-push wakeups: a validated CKNotification triggers a pull + push, and — a detail that saves you hours — it always reports .newData at the end. Report .failed and iOS starts throttling your background pushes, and suddenly "real-time" sync is ten minutes late.
HouseholdIdentity — knowing who you are, synchronously
Half the logic needs to answer "which of these records are mine?" without a network round-trip. So the current user's CloudKit user ID is resolved once and cached in UserDefaults:
func refresh() async {
do {
let recordID = try await CKContainer(identifier: PersistenceController.cloudKitContainerID).userRecordID()
currentUserID = recordID.recordName
await runClaimSweepIfNeeded()
} catch {
Self.logger.info("Could not resolve CloudKit user ID (likely no iCloud account)…")
}
}
That cached ID stamps ownerCloudKitUserID and loggedByMemberID on records, which is how "leave household" knows which rows each person keeps, and how the per-member spending report attributes a coffee to the right partner.
Creating and accepting the share
Owner side: create the zone, then create one CKShare over the whole zone (not per-record), flip your role, reset the change token, re-enqueue everything so it drains into the new zone, and tear down the now-stale personal zone:
_ = try await privateDB.save(CKRecordZone(zoneID: householdZoneID))
let share = CKShare(recordZoneID: householdZoneID)
share[CKShare.SystemFieldKey.title] = "The Smart Budget — Household" as CKRecordValue
share.publicPermission = .none
_ = try await privateDB.save(share)
setRole(.owner)
UserDefaults.standard.removeObject(forKey: Self.serverChangeTokenKey)
await PushQueue.shared.clear()
await reenqueueAllLocalRecords(controller: controller)
await deletePersonalZoneServerSide()
You present that bare CKShare with Apple's native UICloudSharingController — you do not need NSPCKC to drive the system invite UI, which is the thing everyone assumes ties you to the framework. Participant side, accept and persist the owner's ID:
_ = try await container.accept(metadata)
// Owner's CKUserID is encoded in the share's zoneID — persist it so currentZoneID
// can construct the right (zoneName, ownerName) tuple on subsequent pushes/pulls.
let ownerName = metadata.share.recordID.zoneID.ownerName
UserDefaults.standard.set(ownerName, forKey: Self.householdOwnerCKUserIDKey)
setRole(.participant)
Conflict resolution across the whole thing is last-writer-wins on a lastModified timestamp — local wins only if strictly newer, else the remote applies. Simple, and it converges.
Two war stories the code still remembers
The runaway iCloud push storm (fixed 2026-06-29). The skipContexts set (from LocalChangeWatcher) was being cleared on the wrong queue relative to the watcher's block. The clear kept losing the race, so every metadata writeback leaked back out as a fresh push — which the partner pulled and pushed back — an infinite sync loop hammering iCloud. The fix was one line about when the skip-set is cleared. Feedback loops in sync code don't announce themselves; they just quietly melt your rate budget.
The stale-pull guard. A fetch snapshots the current mode+zone before it starts. If acceptShare or disconnect flips the mode mid-flight, the fetch's results are dropped rather than misread — because a "zone gone" error during a fetch you started in the old mode is not the same as "your partner ended the share," and treating it as the latter once landed a spouse's data in the wrong zone.
Would I extract this as an SDK?
There's a clean CloudKitHouseholdSync package hiding in here — HouseholdSync, PushQueue, RecordTranslator, LocalChangeWatcher, dedup, the scene hook. People keep asking. My honest answer: not yet. It has Smart-Budget-specific assumptions baked in (the dedup's lowercased-name keying, the entity dependency ordering) that I'd have to generalize carefully, and I don't want to publish a sync library until it's proven against real households in production. The extraction plan is written down. It ships after the thing has earned it.
The takeaway isn't "never use NSPersistentCloudKitContainer" — for a single-user app that just wants iCloud backup, it's great, and you should. It's this: the moment you mix a custom CKShare into a CD-mirrored private database, you've left the paved road, and the framework gives you no tools to survive off it. Owning the pipeline is more code. It's also the only version where, when something goes wrong with a family's shared money, I have a seam to reach in and fix it — instead of "purge everything and start over."
The Smart Budget is out now on iPhone. Household sharing is a Pro feature; the personal-sync engine underneath it is the same code, running for everyone. Nothing routes through a server I control.
— Omar