How to Build a Local-First iOS App with SwiftData and CloudKit in 2026: A Production Walkthrough
A production walkthrough for building local-first iOS apps with SwiftData, CloudKit private database sync, background notifications, conflict resolution, migrations, and sync testing.
- Why Local-First, and Why SwiftData Now
- Stack Overview
- Model Definition
- Container Configuration
- Background Sync and Remote Change Notifications
- Conflict Resolution
- Schema Migration
- Performance Considerations
- Testing the Sync Layer
- What This Architecture Enables
- FAQs
Local-first architecture is not a trend. It is the correct default for any iOS app where data integrity, offline reliability, or user privacy is a hard requirement. Writes go to the local store first. The app functions without a network connection. Sync happens in the background when connectivity returns.
SwiftData and CloudKit make this pattern more accessible in 2026 than it has ever been. Accessible does not mean automatic. The failure modes are real, non-obvious, and will surface in production at the worst possible moment.
This walkthrough covers the architecture decisions, sync configuration, and conflict-resolution patterns that hold up under production conditions.
Why Local-First, and Why SwiftData Now
Core Data with NSPersistentCloudKitContainer has been the production-grade local-first stack on Apple platforms for several years. SwiftData, introduced in 2023 and substantially matured since, wraps that same persistence layer with a Swift-native API. The underlying sync mechanism is identical: CloudKit private database, zone-based replication, CKRecord deltas.
The difference is ergonomics. SwiftData's @Model macro generates the managed object subclass, the schema migration metadata, and the CloudKit schema compatibility in a single declaration. For a new project in 2026, SwiftData is the right starting point — unless you have an existing Core Data migration path that makes the switch costly.
One constraint to state clearly: SwiftData CloudKit sync requires iCloud entitlements, a CloudKit container, and a signed-in iCloud account on the device. It does not work in the simulator without a real Apple ID. Plan your development environment accordingly.
Stack Overview
SwiftData (@Model, ModelContainer, ModelContext)
↓
NSPersistentCloudKitContainer (managed by SwiftData internally)
↓
CloudKit Private Database (CKRecordZone per container)
↓
Background sync via NSPersistentStoreRemoteChange notifications
No server. No custom backend. Zero bytes of user data routed through any infrastructure you operate. The sync layer is Apple's — which also means it is subject to CloudKit's rate limits, record size limits (1MB per CKRecord), and zone-level constraints.
Model Definition
Start with the model. SwiftData's @Model macro handles Core Data schema generation, but you need to be deliberate about optional vs. non-optional attributes and relationship delete rules.
@Model
final class Entry {
var id: UUID
var title: String
var body: String
var createdAt: Date
var modifiedAt: Date
var isSynced: Bool
@Relationship(deleteRule: .cascade)
var attachments: [Attachment]
init(title: String, body: String) {
self.id = UUID()
self.title = title
self.body = body
self.createdAt = Date()
self.modifiedAt = Date()
self.isSynced = false
}
}
Two things worth noting. First, modifiedAt is your conflict-resolution anchor — last-write-wins by timestamp is CloudKit's default merge strategy, and you need to own that timestamp rather than rely on CKRecord.modificationDate. Second, isSynced is a local flag, not synced to CloudKit. Use it to drive UI state — a pending indicator, a sync badge — without polluting the CloudKit record.
For CloudKit compatibility, every attribute must be optional at the CloudKit schema level, even if SwiftData enforces non-optional locally. SwiftData handles this mapping internally, but it means your CloudKit dashboard will show all fields as optional. Do not fight this.
Container Configuration
@main
struct MyApp: App {
let container: ModelContainer
init() {
let schema = Schema([Entry.self, Attachment.self])
let config = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false,
cloudKitDatabase: .private("iCloud.com.yourcompany.yourapp")
)
do {
container = try ModelContainer(for: schema, configurations: [config])
} catch {
fatalError("ModelContainer initialization failed: \(error)")
}
}
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(container)
}
}
The cloudKitDatabase: .private(...) argument is what enables SwiftData CloudKit sync. The string must match the CloudKit container identifier in your entitlements exactly. A mismatch here fails silently in some configurations — the app launches, local persistence works, and sync never starts. Check the container identifier in both the entitlements file and the CloudKit dashboard before your first TestFlight build.
Background Sync and Remote Change Notifications
SwiftData handles push notification registration and CKSubscription setup automatically when you configure a CloudKit database. What it does not handle is surfacing remote changes to your SwiftUI views.
The mechanism is NSPersistentStoreRemoteChange. When CloudKit delivers changes to the local store, this notification fires. You need to observe it and refresh your ModelContext.
final class SyncMonitor: ObservableObject {
@Published var lastSyncDate: Date?
private var cancellables = Set<AnyCancellable>()
init(container: ModelContainer) {
NotificationCenter.default
.publisher(for: .NSPersistentStoreRemoteChange)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.lastSyncDate = Date()
}
.store(in: &cancellables)
}
}
SwiftUI's @Query macro refreshes automatically when the ModelContext changes, so in most cases views update without additional wiring. SyncMonitor is useful for sync status UI — a last-synced timestamp, a pending indicator, or an error banner.
Conflict Resolution
CloudKit's default merge strategy is last-write-wins at the record level. This works for most fields most of the time. It breaks down when two devices modify the same record while offline and both sync when connectivity returns.
SwiftData does not expose a conflict resolution hook at the application layer in 2026. The merge happens at the NSPersistentCloudKitContainer level before your code sees the data. Your options are:
Option 1: Timestamp-based field-level merge. Store a modifiedAt per field, not per record. On sync, compare field-level timestamps and keep the most recent value per field. This requires a custom sync reconciliation pass after NSPersistentStoreRemoteChange fires.
Option 2: Append-only log. Never mutate records. Append events to a log and derive current state from the log. This is the CRDT-adjacent approach — it eliminates conflicts at the cost of storage and query complexity.
Option 3: Accept last-write-wins and design around it. For most health, fintech, and field-ops apps, the conflict window is short and the data model can be structured so that concurrent edits to the same record are rare. Design your data model to minimize shared mutable state.
The right choice depends on your app's concurrency model. For a single-user app syncing across devices, last-write-wins with a reliable modifiedAt timestamp covers the majority of cases. For multi-user shared data, CloudKit's private database is the wrong tool — you need CloudKit's shared database or a different sync layer entirely.
The offline-first iOS CloudKit sync footguns article covers the specific failure modes in detail, including zone deletion edge cases and migration pitfalls that are easy to miss until they hit production.
Schema Migration
SwiftData handles lightweight migrations automatically — adding optional attributes, adding relationships. It does not handle non-optional attribute additions, attribute type changes, or relationship restructuring without a VersionedSchema and MigrationPlan.
enum AppSchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] { [Entry.self] }
}
enum AppSchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] { [Entry.self, Tag.self] }
}
enum AppMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[AppSchemaV1.self, AppSchemaV2.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2]
}
static let migrateV1toV2 = MigrationStage.lightweight(
fromVersion: AppSchemaV1.self,
toVersion: AppSchemaV2.self
)
}
CloudKit schema migrations require a separate step: update the CloudKit schema in the development environment and promote it to production before shipping the app update. If the app update ships before the CloudKit schema update, sync will fail for records that reference the new schema. The order is fixed — update CloudKit schema in development, promote to production, then submit the app update.
Performance Considerations
SwiftData's @Query macro fetches on the main actor by default. For large datasets, this blocks the main thread during the initial fetch. Use fetchLimit and sortDescriptors to bound the result set.
@Query(
filter: #Predicate<Entry> { $0.isSynced == true },
sort: \Entry.modifiedAt,
order: .reverse,
limit: 50
)
var recentEntries: [Entry]
For background processing — bulk imports, sync reconciliation, derived data computation — create a separate ModelContext on a background actor and merge changes back to the main context via save().
actor BackgroundProcessor {
let container: ModelContainer
func processImport(_ records: [ImportRecord]) async throws {
let context = ModelContext(container)
for record in records {
let entry = Entry(title: record.title, body: record.body)
context.insert(entry)
}
try context.save()
}
}
The SwiftUI architecture guide covers the view-layer patterns that pair with this data layer, including how to structure ModelContext ownership across a modular SwiftUI hierarchy.
Testing the Sync Layer
Testing SwiftData CloudKit sync in isolation is difficult. The sync layer depends on a real CloudKit container, a signed-in iCloud account, and network availability. Unit tests cannot reach it.
The practical approach is to test at two levels.
Local persistence tests use ModelConfiguration(isStoredInMemoryOnly: true) to create an in-memory store. These cover model logic, predicates, and migration plans without any CloudKit dependency.
Integration tests run on device against a dedicated test CloudKit container — separate from production. Use a separate bundle identifier for your test scheme and a separate CloudKit container. Never run integration tests against your production container.
For end-to-end sync testing, two physical devices signed into the same iCloud account is the minimum setup. Simulators share the same iCloud account state and do not reliably reproduce the sync latency and conflict scenarios you need to validate.
What This Architecture Enables
A SwiftData + CloudKit local-first app gives you full offline functionality with automatic background sync, zero custom backend infrastructure, and no user data transiting your servers. For health, fintech, and legal apps where data residency and privacy are non-negotiable, this is the correct default architecture.
The tradeoffs are real: CloudKit's 1MB record limit, the absence of application-level conflict hooks, the iCloud account dependency, and the CloudKit schema promotion workflow all add friction that a custom backend does not have. For most single-user or small-team iOS apps, those tradeoffs are worth it.
If your app requires multi-user shared data, real-time collaboration, or server-side computation, this stack is not sufficient on its own. That is a different architecture problem.
For teams building on this foundation and adding on-device AI inference, the Swift 6 AI integration guide covers how Core ML and Apple Foundation Models fit into a local-first data layer without breaking the zero-bytes-to-server constraint.
FAQs
Does SwiftData CloudKit sync work without an iCloud account?
No. CloudKit private database sync requires a signed-in iCloud account on the device. If the account is signed out, local persistence continues to work but sync stops. Design your app to function fully offline and degrade gracefully when iCloud is unavailable — do not block on sync availability.
What happens to data if a user deletes the app and reinstalls?
The local SwiftData store is deleted with the app. On reinstall, CloudKit re-syncs all records from the private database to the new local store. The sync completes when all CKRecord deltas have been applied. For large datasets, this initial sync can take several minutes. Show a sync progress indicator rather than an empty state during this period.
Can SwiftData and Core Data share the same CloudKit container?
Yes, with caution. SwiftData uses NSPersistentCloudKitContainer internally, so the CloudKit schema is compatible. If you are migrating an existing Core Data + CloudKit app to SwiftData, the CKRecord types and field names must match exactly. A schema mismatch causes silent sync failures. Validate the CloudKit schema in the development environment before migrating production data.
How do you handle CloudKit's 1MB record size limit?
Store large binary data — images, documents, audio — as CKAsset references, not inline in CKRecord fields. In SwiftData, this means storing the asset as an External attribute or managing the CKAsset upload separately. Records that approach the 1MB limit in testing will exceed it in production once CloudKit adds its own metadata overhead.
What is the right conflict resolution strategy for a health app?
For health data where each data point is a discrete measurement — heart rate, glucose reading, step count — use an append-only log model. Each measurement is a separate record with a stable UUID and a recorded timestamp. Mutations are rare by design. Last-write-wins is safe because the data points themselves are immutable after creation.
How do you test SwiftData CloudKit sync in CI?
You cannot test the CloudKit sync layer in CI without a real iCloud account and network access, which most CI environments do not support. Test local persistence logic with in-memory stores in CI. Run CloudKit integration tests manually on device before each release, using a dedicated test CloudKit container separate from production.
When should you not use SwiftData + CloudKit?
When your app requires multi-user shared data with concurrent edits, real-time collaboration, server-side business logic, or data that must be accessible from a web interface. CloudKit's private database is a single-user sync mechanism. For shared data, evaluate CloudKit's shared database or a purpose-built sync layer. For any requirement that puts data on your servers, the local-first constraint is broken by definition.