Building Production iCloud Sync Apps with SwiftUI and CloudKit in Antigravity
Build a production iCloud sync iOS app with SwiftUI and CloudKit in Antigravity. Covers CKRecord design, offline support, and the queryable-index and Development-to-Production schema pitfalls that break release builds.
A task I added on my iPhone simply would not appear on my iPad. Working solo on an app, it took me an uncomfortably long time to see where CloudKit's sync had quietly stalled. The docs are thick, the errors are terse, and the schema drifts between Development and Production — most of the reasons people avoid CloudKit come down to this lack of visibility.
What follows is the route I took to production with SwiftUI + CloudKit — real-time sync across devices, with Antigravity IDE's agents working alongside me. Rather than only making it run, I'll leave the reasoning next to each design choice.
By the end of this article, you'll be able to implement:
CloudKit container setup and CKRecord model design
Real-time data sync using @Observable + CloudKit
Offline-first architecture with conflict resolution
Silent push notifications via CKSubscription
Prerequisites: SwiftUI fundamentals, basic Antigravity familiarity, and an active Apple Developer account.
Environment Setup
Required Tools
Xcode 26 (macOS 15.x Sequoia or later)
Apple Developer Program membership
Antigravity 1.20+
iOS 18+ deployment target
Configuring CloudKit in Xcode with Antigravity
Rather than clicking through Xcode's GUI manually, give the Antigravity agent a direct instruction:
Antigravity prompt:
"Create a new SwiftUI iOS app. Add CloudKit and Push Notifications capabilities.
Set the CloudKit container identifier to iCloud.com.example.myapp.cloudkit
and configure the entitlements file."
Antigravity automatically handles:
Adding CloudKit capability to the Xcode project
Adding Push Notifications capability
Inserting required entries into Info.plist
Setting the CloudKit container identifier in .entitlements
In Xcode 26, capability configuration has migrated to JSON-based project files, which Antigravity agents can edit directly — eliminating the GUI bottleneck that previously required manual interaction.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A decision framework for when to choose CloudKit — and when not to — from an indie developer cost view
✦The queryable-index and Development-to-Production schema pitfalls that return empty queries in release builds
✦Complete cross-device real-time sync with @Observable and CKSubscription
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
"Design a CloudKit schema for a task management app with Task, Tag, and Comment
record types. Include all fields and relationships, formatted for CloudKit Dashboard input."
Example schema output:
Record Type
Field
CloudKit Type
Task
title
String
Task
isCompleted
Int(64)
Task
dueDate
Date/Time
Task
priority
Int(64)
Task
tagReference
Reference
Tag
name
String
Tag
color
String
Comment
body
String
Comment
taskReference
Reference
Comment
createdAt
Date/Time
Choosing the Right Database Zone
CloudKit offers three zones: Private (user-owned data, billed to user's iCloud storage), Shared (collaborative data between specific users, iOS 15+), and Public (world-readable app data). For most apps, start with the Private Database — it's free for developers and automatically scoped to each user's iCloud account.
Code Example 1: CloudKit Data Store with @Observable
The following is a complete CloudKit data store implementation using Swift Concurrency and the @Observable macro. Prompt Antigravity: "Implement an @Observable CloudKit data store for Task records using async/await."
import CloudKitimport Observation// CloudKit container identifier (customize per project)private let containerIdentifier = "iCloud.com.example.myapp.cloudkit"// CloudKit Record Type constantsenum RecordType { static let task = "Task" static let tag = "Tag"}@Observablefinal class TaskStore { var tasks: [TaskItem] = [] var isLoading = false var errorMessage: String? private let container: CKContainer private let privateDB: CKDatabase init() { self.container = CKContainer(identifier: containerIdentifier) self.privateDB = container.privateCloudDatabase } // --- Fetch all tasks --- func fetchTasks() async { isLoading = true defer { isLoading = false } do { let query = CKQuery( recordType: RecordType.task, predicate: NSPredicate(value: true) ) query.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)] let results = try await privateDB.records(matching: query) let records = try results.matchResults.compactMap { _, result in try result.get() } await MainActor.run { self.tasks = records.compactMap(TaskItem.init) } } catch { await MainActor.run { self.errorMessage = error.localizedDescription } } } // --- Save a task (create or update) --- func saveTask(_ item: TaskItem) async throws { let record = item.toCKRecord() let savedRecord = try await privateDB.save(record) let savedItem = TaskItem(record: savedRecord)! await MainActor.run { if let index = self.tasks.firstIndex(where: { $0.id == item.id }) { self.tasks[index] = savedItem } else { self.tasks.insert(savedItem, at: 0) } } }}// --- TaskItem model ---struct TaskItem: Identifiable, Hashable { let id: CKRecord.ID var title: String var isCompleted: Bool var dueDate: Date? var priority: Int // Initialize from CKRecord init?(record: CKRecord) { guard let title = record["title"] as? String else { return nil } self.id = record.recordID self.title = title self.isCompleted = (record["isCompleted"] as? Int64 ?? 0) == 1 self.dueDate = record["dueDate"] as? Date self.priority = Int(record["priority"] as? Int64 ?? 0) } // Convert to CKRecord func toCKRecord() -> CKRecord { let record = CKRecord(recordType: RecordType.task, recordID: id) record["title"] = title record["isCompleted"] = isCompleted ? 1 : 0 record["dueDate"] = dueDate record["priority"] = Int64(priority) return record }}// Expected: tasks array auto-updates in SwiftUI views via @Observable
Three key design decisions here: Swift Concurrency keeps CloudKit's async APIs readable without callback nesting; @Observable enables zero-boilerplate SwiftUI reactivity; and using CKRecord.ID as the model's identifier ensures CloudKit ID consistency across the app.
Code Example 2: SwiftUI View with Real-Time Sync
This integrates TaskStore into a SwiftUI view and registers a CKSubscription to detect changes from other devices.
import SwiftUIimport CloudKitstruct TaskListView: View { @State private var store = TaskStore() @State private var showAddTask = false var body: some View { NavigationStack { Group { if store.isLoading && store.tasks.isEmpty { ProgressView("Loading...") } else { List { ForEach(store.tasks) { task in TaskRowView(task: task) { updated in Task { try? await store.saveTask(updated) } } } } } } .navigationTitle("Tasks") .toolbar { ToolbarItem(placement: .primaryAction) { Button { showAddTask = true } label: { Image(systemName: "plus") } } } .sheet(isPresented: $showAddTask) { AddTaskView { newTask in Task { try? await store.saveTask(newTask) } } } .task { await store.fetchTasks() await store.subscribeToChanges() } .onReceive(NotificationCenter.default.publisher( for: .CKAccountChanged )) { _ in Task { await store.fetchTasks() } } } }}extension TaskStore { func subscribeToChanges() async { let subscriptionID = "task-changes" // Check for existing subscription to avoid duplicates do { _ = try await privateDB.subscription(for: subscriptionID) } catch { // Not yet registered — create it let subscription = CKQuerySubscription( recordType: RecordType.task, predicate: NSPredicate(value: true), subscriptionID: subscriptionID, options: [ .firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion ] ) // Silent push: wakes app in background to refresh data let info = CKSubscription.NotificationInfo() info.shouldSendContentAvailable = true subscription.notificationInfo = info try? await privateDB.save(subscription) } }}
The shouldSendContentAvailable = true flag enables silent pushes: when another device modifies a record, iOS wakes your app in the background and triggers a fetch — so users always see fresh data when they open the app, without any user-visible notification.
Code Example 3: Conflict Resolution and Offline Support
CloudKit raises CKError.serverRecordChanged when two devices edit the same record concurrently. Here's how to handle it with a merge strategy.
extension TaskStore { // Save with conflict resolution func saveWithConflictResolution(_ item: TaskItem) async throws { do { try await saveTask(item) } catch let error as CKError where error.code == .serverRecordChanged { guard let serverRecord = error.userInfo[CKRecordChangedErrorServerRecordKey] as? CKRecord, let clientRecord = error.userInfo[CKRecordChangedErrorClientRecordKey] as? CKRecord else { throw error } // Merge strategy: // - Title: client wins (user is actively typing) // - isCompleted: server wins (respect completion from other devices) let merged = serverRecord merged["title"] = clientRecord["title"] let saved = try await privateDB.save(merged) await MainActor.run { if let i = tasks.firstIndex(where: { $0.id == item.id }) { tasks[i] = TaskItem(record: saved)! } } } } // Queue writes when offline func saveOfflineIfNeeded(_ item: TaskItem) async { do { try await saveWithConflictResolution(item) } catch let err as CKError where err.code == .networkUnavailable || err.code == .networkFailure { PendingChangesQueue.shared.enqueue(item) print("Queued offline change: \(item.title)") } catch { await MainActor.run { self.errorMessage = error.localizedDescription } } }}final class PendingChangesQueue { static let shared = PendingChangesQueue() private var queue: [TaskItem] = [] var count: Int { queue.count } func enqueue(_ item: TaskItem) { // Deduplicate: keep only the latest change per ID queue.removeAll { $0.id == item.id } queue.append(item) } // Flush on network reconnection func flushAll(to store: TaskStore) async { let pending = queue queue.removeAll() for item in pending { try? await store.saveWithConflictResolution(item) } print("Flushed \(pending.count) pending changes") }}// Expected: Conflicting edits are merged rather than rejected; offline changes// persist in the queue and sync automatically on reconnection.
For production apps, persist PendingChangesQueue to SwiftData or Core Data so pending changes survive app termination. Pair it with NWPathMonitor (Network.framework) to auto-flush on reconnection.
Design Decision: When to Choose CloudKit — and When Not To
Before writing a line of sync code, I stop on one question: is CloudKit even the right backend here? Skip this and you pay a migration cost later. On a solo project with limited hours, that first decision shapes the entire effort.
CloudKit shines when these conditions line up:
Dimension
CloudKit fits
Consider alternatives
Users
Apple platforms only
Android / Web ship at the same time
Data ownership
Per-user data (Private DB)
You need server-side aggregation
Running cost
Uses the user's iCloud quota — effectively free
You can absorb usage-based billing
Auth
Reuse the Apple ID directly
You need a custom account system
Ops
You'd rather not run a backend (indie dev)
You can maintain servers full time
For indie development, the fact that storage runs on the user's iCloud quota — no per-user billing for you — is decisive. With Firebase or a custom API, cost climbs as users grow. But the moment one requirement needs the server to read data across users (leaderboards, recommendations, an admin view), a Private Database alone leaves you stuck. Write "does the server need to read this data?" into your requirements up front.
Personally, I prefer to ship an MVP that lives entirely in the Private Database, then move only the parts that truly need aggregation onto a small custom API later. Carrying both from day one means juggling auth and sync twice, which complicates your agent prompts and slows you down.
Leveraging Antigravity for Faster Development
Freeze the schema into one file before you parallelize
When you split CloudKit work across agents, starting them in parallel breaks things more often than not. The agent writing the view layer types "isCompleted"; the agent writing the data layer types "completed".
CloudKit does not reject a save containing an unknown field, and the query side quietly returns zero results. The build succeeds, the unit tests pass, and you find out on device.
After running into that twice, I fixed the order. One agent writes the schema and nothing else, its output lands in a single file, and only then does the split begin.
// CloudKitSchema.swift — the single contract every agent referencesenum TaskSchema { static let recordType = "Task" enum Field { static let title = "title" static let isCompleted = "isCompleted" static let dueDate = "dueDate" static let modifiedAt = "modifiedAt" } /// Fields that need a queryable index in the CloudKit Dashboard static let queryableFields = [Field.isCompleted, Field.dueDate, Field.modifiedAt]}
With that file in place, add one line to your instructions — never write field names as string literals, always go through TaskSchema.Field — and mismatched keys in generated code effectively disappear. Keeping queryableFields as a constant lets you pull the pre-promotion checklist described later straight out of code instead of from memory.
Once the schema is settled, the split itself is straightforward: the data layer (CKRecord conversion, saving, fetching), the SwiftUI view layer, and XCTest with CloudKit mocks. The point is that none of those three should start before the schema is settled.
A long AGENTS.md goes unread. For CloudKit, what belongs there narrows down to the contract.
### CloudKit contract- Container: `iCloud.net.example.tasks` (do not change)- Database: Private only. Public / Shared are out of scope for now- Record Type: `Task` (field definitions live in `CloudKitSchema.swift`)- Never write field names as string literals; go through `TaskSchema.Field`- Save with `modifyRecords(saving:deleting:savePolicy:atomically:)` (do not use the `CKModifyRecordsOperation` completion-block form)- Conflict resolution: last write wins on `modifiedAt`. No optimistic locking- New fields must be Optional; assume existing records have no value
The part that earns its keep is the list of things not to use. Without explicit prohibitions, agents drift back toward the older style that dominates their training data — and CloudKit has a deep archive of closure-based material behind it.
Leave no completion-block APIs behind
CloudKit's closure APIs were largely replaced in iOS 15. CKQueryOperation's recordFetchedBlock and queryCompletionBlock, CKModifyRecordsOperation's perRecordCompletionBlock and modifyRecordsCompletionBlock, and CKFetchRecordZoneChangesOperation's recordChangedBlock are all deprecated.
The awkward part is that deprecated code still compiles. It survives inside the warning noise, and your partial-failure handling stays stuck in the old shape while everything around it moves on.
The matchResults returned by records(matching:) carries a per-record Result. You get exactly which records failed rather than a single success-or-failure verdict, which makes the CKError.partialFailure handling in the next section fall out naturally. Doing the same through the old completion handler means digging through userInfo yourself.
Once a requirement like surfacing sync progress on the Lock Screen enters the picture, the design branches in a different direction. That territory is covered in Antigravity × SwiftUI Live Activities & Dynamic Island.
Troubleshooting
CKError.notAuthenticated — User is not signed into iCloud.
// Check iCloud status at app launchlet status = try await CKContainer.default().accountStatus()guard status == .available else { showICloudSignInPrompt = true return}
CKError.quotaExceeded — User's iCloud storage is full. Store binary assets (images, audio) as CKAsset separately from structured records. Adding a storage cleanup feature for stale data is also worth considering.
CKError.partialFailure — Some records in a batch operation failed. Read error.userInfo[CKPartialErrorsByItemIDKey] to identify failed records and retry only those — retrying the full batch risks overwriting successfully saved data.
Silent push not firing — Confirm the Push Notifications capability is present. Silent pushes don't work in Simulator; always test on a physical device. Note that development and production builds use different APNs environments — run a TestFlight build before release to catch environment mismatches.
Performance Considerations
Paginated Queries for Large Datasets
CloudKit returns up to 400 records per query by default. To get the rest, you have to hand the CKQueryOperation.Cursor from the previous call into the next one. Miss that, and you keep re-reading the first page while the cursor never goes nil — the loop simply does not end. The second pass onward calls records(continuingMatchFrom:), not records(matching:).
func fetchAllTasks() async throws -> [CKRecord] { var all: [CKRecord] = [] let query = CKQuery( recordType: RecordType.task, predicate: NSPredicate(value: true) ) // First pass: hand over the query itself let (firstPage, firstCursor) = try await privateDB.records( matching: query, resultsLimit: 200 ) all += try firstPage.map { _, result in try result.get() } // Later passes: hand over the previous cursor var cursor = firstCursor while let current = cursor { let (page, nextCursor) = try await privateDB.records( continuingMatchFrom: current, resultsLimit: 200 ) all += try page.map { _, result in try result.get() } cursor = nextCursor } return all // Expected: all records collected, cursor becomes nil, loop exits}
The returned tuple is (matchResults:, queryCursor:), and matchResults is a per-record Result. Calling try result.get() inside the map is deliberate: if one record is malformed, I want to hear about it right there. Swallowing it and returning the rest looks kinder, yet in a sync context I've come to think that succeeding while incomplete costs more later.
Delta Sync with Change Tokens
Use CKFetchRecordZoneChangesOperation with a server change token to fetch only records modified since the last sync. This dramatically reduces bandwidth and CPU usage compared to full re-fetches. Store the token in UserDefaults or SwiftData and refresh it after each successful sync.
The server decides how long to wait
The last bug to survive in my sync code showed up only when I edited on two physical devices back to back: saves started failing. The log said CKError.requestRateLimited.
At first I reached for plain exponential backoff — one second, two, four. It didn't work well. Too short and the requests keep getting turned away; too long and sync feels sluggish. Choosing the number myself was the mistake.
CloudKit tells you how long to wait. CKError exposes retryAfterSeconds (in Objective-C, CKErrorRetryAfterKey in userInfo), and it arrives with zoneBusy and serviceUnavailable as well as requestRateLimited.
Let the server set the delay; keep only the give-up count for yourself. Beyond sync, that's the line I hold anywhere I call an external service.
// Retry that honors retryAfterSeconds; we own only the attempt ceilingfunc withCloudKitRetry<T>( maxAttempts: Int = 3, _ body: () async throws -> T) async throws -> T { var attempt = 0 while true { do { return try await body() } catch let error as CKError { attempt += 1 // Errors without a retry interval won't change with time guard let wait = error.retryAfterSeconds, attempt < maxAttempts else { throw error } try await Task.sleep(for: .seconds(wait)) } }}// Call sitelet saved = try await withCloudKitRetry { try await privateDB.save(record)}// Expected: on rate limiting, waits exactly as long as the server asked,// then rethrows the CKError once the attempt ceiling is reached
Errors where retryAfterSeconds is nil — notAuthenticated, quotaExceeded — are excluded on purpose, because waiting changes nothing. Retry everything indiscriminately and a device that isn't signed in to iCloud gets an app that freezes for tens of seconds without saying why.
Ask an agent to write the save path and it will almost always propose textbook exponential backoff. When I read generated code, this is the first thing I look for.
CKError
retryAfterSeconds
What to do
requestRateLimited
usually present
Wait the given interval, resend
zoneBusy
usually present
Wait the given interval, resend
serviceUnavailable
usually present
Wait the given interval, resend
notAuthenticated
absent
Prompt for iCloud sign-in
quotaExceeded
absent
Explain the storage state, stop
partialFailure
absent
Judge each failed record on its own
Two Pitfalls That Return Empty Queries in Production
It works flawlessly in the Simulator, then returns nothing the instant it lands on TestFlight — this cost me more CloudKit hours than anything else. The cause almost always narrows to two dashboard-side settings, both easy to miss if you leave everything to the Antigravity agent.
Pitfall 1: Missing queryable indexes
CloudKit won't let you query or sort on a field unless its schema marks it "Queryable," "Sortable," or "Searchable." The code compiles fine, so the failure only shows up in production:
// Surfaces mainly in production// CKError.invalidArguments// "Field 'createdAt' is not marked queryable"//// Fix: CloudKit Dashboard -> Schema -> Indexes, set// createdAt to Sortable / Queryable, then Deploy the// Development schema to Production.query.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]
Sorting on recordName (a system field) needs the same Queryable flag. Indexes may appear to be added automatically during development, but that never carries into production. Treat this as a manual checklist item.
Pitfall 2: Forgetting to promote the Development schema
CloudKit keeps two schema environments: Development and Production. Saving a new Record Type or field from code adds it to Development automatically — but it is never promoted to Production for you. TestFlight and App Store builds read the Production schema, so a forgotten promotion produces the classic "works in the Simulator, empty in the distributed build" symptom.
Environment
Schema updates
Which builds read it
Development
Added automatically on code run
Xcode device / Simulator runs
Production
Manual Deploy in the Dashboard required
TestFlight / App Store distribution
My operating rule: whenever the schema changes, run "Deploy Schema Changes" in the CloudKit Dashboard before release. I keep this line in AGENTS.md so the agent reminds me every time it generates a migration. Because a field, once in Production, cannot be deleted (and its type cannot change), add fields carefully and keep changes backward compatible.
Backward compatibility when adding fields
When you add a field to an app that is already in users' hands, always read from CKRecord with a default fallback. The ?? 0 in the earlier TaskItem(record:) exists for exactly this reason: records saved by older versions have no new field, so writing code that assumes a non-nil value will crash in production.
Summary
CloudKit's difficulty hides in the dashboard and the environment gap far more than in the code. Turn three things into fixed procedure — queryable indexes, schema promotion, and passing the cursor along — and the surface you can safely hand to an agent grows noticeably.
Looking back, none of the places I got stuck were places where the code was wrong. They were places where I wasn't watching the state that lives outside the code. Given what sync is, I'll probably miss something of the same shape again. That's why every time I notice one, I add a single line to the checklist rather than a paragraph.
As a next step, wire up an @Observable store backed only by the Private Database and watch a task travel between two physical devices. Seeing sync happen in front of you is what carries you past the setup friction.
If you've been stuck at the same spot, I hope this shortens the detour by at least a stretch.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.