author: Wesley Matlock read_time: 6 —
“72 seasons of silence, one sync engine to rule them all.”
When I kicked off PushTo100, the idea was to build a guided pushup program that helps users hit 100 reps in a day. I wanted to challenge myself to ship something with SwiftUI and SwiftData — and CloudKit was my excuse to learn real-world sync. Let users track their progress across iPhone, iPad, and more, all without losing a single rep.
At least, that was the dream. Flip a couple switches, and boom — free sync, right?
Right?
Not even close.
What started as a fun exploration of SwiftData and SwiftUI quickly turned into a 72-season saga of data loss, mystery conflicts, and silent sync failures. The kind of bugs that don’t show up in your logs but slap your users in the face with missing progress and duplicated entries. The kind that make you second-guess shipping at all.
So I built my own system: 72 Syncs — an upload-first, conflict-aware, SwiftData-compatible sync engine backed by CloudKit. It powers PushTo100 in production today. In this post, I’m walking through the exact sync system that ships in PushTo100 — real code, architecture choices, test cases, and every pitfall I hit along the way.
☁️ High-Level iCloud Setup
Before you touch a single record, make sure this stuff’s actually turned on — trust me, forgetting one checkbox here will ruin your entire morning.
- An iCloud container enabled in your Xcode project (Target → Signing & Capabilities → + Capability → iCloud → enable “CloudKit”)
- The correct container identifier (e.g. iCloud.com.yourcompany.appname)
- iCloud enabled in the Apple Developer portal with schema deployed via Xcode
Gotchas
- If your iCloud container isn’t deployed correctly, CloudKit operations fail silently. You won’t see logs. You’ll just get empty results.
- Simulator requires a signed-in iCloud account. Always test sync on both Simulator and a real device.
- Schema changes? Manually deploy them in the CloudKit dashboard. Forgetting this is the #1 cause of sync failures during QA.
Tips
- Enable CKContainer.default().accountStatus checks and show a proper message when the user isn’t signed into iCloud.
- CloudKit’s Dashboard lets you simulate changes, delete records, and monitor what’s actually happening under the hood.
🔄 Upload-First Sync Engine
The guiding principle: trust the device first. In practice, this means our sync engine always starts with:
- Uploading local changes
- Downloading latest server state
- Running our resolution strategy
- Applying results to both sides
Sync Sequence
swift
[Local Change]
→ [Upload to CloudKit]
→ [Download CloudKit Records]
→ [Run Conflict Resolver]
→ [Apply to SwiftData]
→ [Push Resolution Back to CloudKit (if needed)]
swift
Sync Flow in Practice
swift
func syncNow() async {
await uploadLocalData()
let remoteData = await fetchRemoteData()
let resolved = conflictResolver.merge(local: localData, remote: remoteData)
await applyResolution(resolved)
}
swift
What It Does
- uploadLocalData(): Pushes changed SwiftData models to CloudKit
- fetchRemoteData(): Queries CloudKit for known record IDs
- merge(…): Custom resolution logic
- applyResolution(…): Saves merged results locally and remotely
Advanced Tips
- Separate sync pipelines by entity (e.g., WorkoutModel vs. PushupSession)
- Queue sync using an actor to prevent collisions
- Consider BGTaskScheduler for background sync
🔁 SwiftData ↔️ CloudKit Model Mapping
Mapping Code
swift
extension WorkoutModel {
func toCKRecord() -> CKRecord {
let record = CKRecord(recordType: "Workout", recordID: self.cloudKitID)
record["progress"] = self.progressValue as CKRecordValue
record["updatedAt"] = self.updatedAt as CKRecordValue
return record
}
swift
swift
static func fromCKRecord(_ record: CKRecord) -> WorkoutModel {
WorkoutModel(
progressValue: record["progress"] as? Int ?? 0,
updatedAt: record["updatedAt"] as? Date ?? .distantPast
)
}
}
swift
Pro Tip
Always check for nil values when deserializing CKRecords. CloudKit won’t stop you from writing half-baked records.
🧠 Conflict Resolution Strategies
Our custom resolver does the following:
- Compare updatedAt timestamps
- If equal, compare progressValue
- If still equal, prefer local changes
Conflict Resolution Logic
swift
func resolve(_ local: WorkoutModel, _ remote: WorkoutModel) -> WorkoutModel {
if local.updatedAt > remote.updatedAt {
return local
} else if local.updatedAt < remote.updatedAt {
return remote
} else {
return local.progressValue > remote.progressValue ? local : remote
}
}
swift
Design Notes
- Always update updatedAt on save
- Store a conflict log locally for QA and support
Supported Strategies
swift
enum ConflictPolicy {
case newestWins
case localWins
case remoteWins
case manualResolution
}
swift
🛠 Auto-Sync and Manual Control
We support both automatic sync (on resume, after changes) and manual triggers from the settings panel.
CloudKitSettingsView
swift
struct CloudKitSettingsView: View {
@AppStorage("autoSyncEnabled") var autoSync = false
var body: some View {
Form {
Toggle("Automatic Sync", isOn: $autoSync)
Button("Sync Now") {
Task { await syncNow() }
}
if let error = syncError {
Text("Error: \(error.localizedDescription)")
}
}
}
}
swift
Pro Tips
- Use a SyncStatusViewModel with @Observable to monitor state and errors
- Show last sync date and make the sync state visible to build trust with the user
🧪 SwiftTest Coverage
We wrote test cases to validate all sync paths and merge outcomes.
Timestamp Test
swift
func testNewerTimestampWins() {
let local = WorkoutModel(updatedAt: .now, progressValue: 3)
let remote = WorkoutModel(updatedAt: .now.addingTimeInterval(-120), progressValue: 10)
let resolved = conflictResolver.resolve(local, remote)
XCTAssertEqual(resolved, local)
}
swift
Equal Timestamp Test
swift
func testProgressWinsWhenTimestampEqual() {
let now = Date()
let local = WorkoutModel(updatedAt: now, progressValue: 2)
let remote = WorkoutModel(updatedAt: now, progressValue: 4)
let resolved = conflictResolver.resolve(local, remote)
XCTAssertEqual(resolved, remote)
}
swift
Sync Test Matrix
- ✅ Newer timestamp wins
- ✅ Higher progress wins when timestamps match
- ✅ Missing CloudKit record returns fallback
- ✅ Invalid CKRecord skipped
- ✅ Merge conflict resolution applied and saved
- ✅ Performance test for 100+ records
⚠️ Advanced CloudKit Gotchas
CloudKit limits each record to about 1 MB of non-asset data — which means large strings, blobs, or deeply nested structures can silently break your sync. For anything big, like images or binary files, Apple recommends using CKAsset. These are stored separately and can go up to 50 MB apiece, making them a safer choice for heavier payloads.
- CloudKit throttles excessive writes — back off and retry with delay
- Each record has a ~1 MB limit (excluding assets)
- Use CKAsset for images or large binary blobs (up to 50 MB)
- Avoid stuffing base64 data directly into fields — it breaks sync
- Handle CKError.partialFailure per-record, not globally
- Background sync requires BGTaskScheduler or the system may suspend your tasks
🧱 Architecture Overview
Here’s how we broke down sync responsibilities:
swift
CloudKitSyncEngine
├── UploadManager
├── DownloadManager
├── ConflictResolver
├── LocalDataCoordinator
└── SyncStatusReporter
swift
This setup helped me isolate weird sync bugs fast — especially when uploads mysteriously failed or merged state got stuck.
✅ Pre-Production Sync Checklist
- iCloud Container configured and deployed
- Conflict resolution strategy tested
- Auto-sync toggled via AppStorage
- Manual sync and last sync diagnostics shown
- Account status surfaced to user
- Schema tested on real devices
- CloudKit usage monitored via Dashboard
🎸 Wrapping Up: Nothing Else Matters, Except Sync
CloudKit gets a bad rap, and honestly, it deserves some of it. But when paired with SwiftData and a smart, upload-first system like 72 Syncs, it can hold its own — and keep your user data intact across devices.
PushTo100 is live on the App Store now. It’s still evolving, and the sync engine will continue to grow with new Apple updates. iOS 18’s background update improvements? Already on my radar.
Got your own sync horror story? Or a battle-tested strategy? I want to hear it.
More Real-World iOS Survival Stories
Check out the rest of the blog (medium.com/@wesleymatlock) for more SwiftUI, SwiftData, and sync patterns pulled straight from production apps.
Share your stories, steal my code, or just come hang. It’s more fun than resolving merge conflicts.
Sync on.
– Wes
Originally published on Medium