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.
In this article we build a SwiftUI + CloudKit app that syncs across devices in real time, all the way to production, using Antigravity IDE's agents as a partner. Rather than just making it run, I'll share the reasoning behind 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
Parallel Agents with Manager Surface
Antigravity's Manager Surface lets you run multiple specialized agents simultaneously. For CloudKit integration, a productive split is:
Agent A: CloudKit schema design and CKRecord model generation
This parallel approach compresses 2–3 days of CloudKit integration work into a few hours.
AGENTS.md for Consistent Context
Place an AGENTS.md at the project root documenting your CloudKit container ID, schema decisions, and architectural choices. Every agent shares this context, preventing inconsistencies across generated code. See the Antigravity SwiftUI Skills Guide for AGENTS.md templates specific to iOS development.
Staying Current with iOS 26
The iOS 26 / Xcode 26 Migration Guide covers the latest CloudKit API changes in iOS 26. Antigravity agents stay updated with the current iOS SDK, automatically flagging deprecated APIs and suggesting modern replacements.
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. For larger datasets, use cursor-based pagination:
func fetchAllTasks() async throws -> [CKRecord] { var all: [CKRecord] = [] let query = CKQuery( recordType: RecordType.task, predicate: NSPredicate(value: true) ) var cursor: CKQueryOperation.Cursor? = nil repeat { let (results, next) = try await privateDB.records( matching: query, resultsLimit: 200 ) all += try results.matchResults.map { _, r in try r.get() } cursor = next } while cursor != nil return all // Expected: returns all records regardless of total count}
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.
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
In this advanced guide, we covered production-grade SwiftUI + CloudKit integration driven by Antigravity:
@Observable + CKRecord for type-safe, reactive data stores
CKSubscription silent push for seamless real-time cross-device sync
Conflict resolution strategies for concurrent edits across devices
Offline-first design with a pending changes queue and automatic flush
Multi-agent parallel development via Antigravity's Manager Surface
CloudKit's difficulty hides in the dashboard and the environment gap far more than in the code. Once you turn the two — queryable indexes and schema promotion — into a fixed checklist, the surface you can safely hand to an agent grows considerably.
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. Thank you for reading.
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.