Why local-first?
The pitch is simple: your app works offline, data lives on the user's device, and the server is optional. No loading spinners on the subway. No "connection lost" toasts. No backend to maintain at 3 a.m.
For Second Brain, our AI memory companion, local-first was non-negotiable. People capture private thoughts — those cannot live on our servers. For Fed?, a pet feeding tracker shared across families, local-first means the app stays useful even when someone's phone is in airplane mode at the vet.
Apple gives you the tools: SwiftData for the local persistence layer, CloudKit for sync. On paper it looks like a two-line setup. In production, it's a minefield of undocumented behaviors, silent failures, and schema decisions you can never undo.
This post covers the lessons we learned the hard way. None of this is in Apple's sample code.
Schema migration: the one-way door
SwiftData supports lightweight migration — adding a new field with a default value, renaming a property — and it handles these automatically. If you stay inside these bounds, updates are painless. The moment you step outside, you need a custom MigrationPlan, and the documentation for that is sparse to the point of being adversarial.
What lightweight migration actually covers
- Adding a new optional property (or one with a default value)
- Removing a property
- Renaming a property (with
@Attribute(originalName:))
That's it. Changing a property's type, restructuring relationships, or splitting one model into two all require a staged migration. The stages must be defined in order, and each stage references versioned schema classes that you maintain in perpetuity.
Gotcha: SwiftData's migration plan silently does nothing if your version identifiers don't match exactly. There's no error, no crash — your migration simply doesn't run, and your users get corrupted data the next time they open the app. Test every migration path on a real device with a real database from the previous version.
CloudKit makes it worse
Here's the rule that bites every new CloudKit developer: once you deploy a record type to the production CloudKit container, you cannot delete or rename fields. You can only add new ones.
This means every schema mistake is permanent in production. If you shipped a field called isCompleted and later realize it should be an enum, you're now maintaining both isCompleted and a new status field — forever.
Our approach after getting burned:
- Design your schema as if it's a public API. Field names should be clear, types should be future-proof. Use strings for anything that might expand (status fields, categories).
- Always deploy to the development container first. You can reset the development schema. You cannot reset production.
- Add fields, never remove. On the SwiftData side, remove properties freely. On the CloudKit side, orphaned fields just sit there. It's ugly but safe.
- Version your models explicitly. We use
schemaVersioninteger properties in our root models, so the app can detect and handle data from older versions without relying on CloudKit's container state.
// Version your models explicitly
@Model
final class Memory {
var schemaVersion: Int = 2
var content: String = ""
var createdAt: Date = Date()
// v2: added category (was previously unclassified)
var category: String = "general"
// v1 field — kept for CloudKit compatibility, unused in code
// var isProcessed: Bool = false
}
Conflict resolution: "last writer wins" is not a strategy
CloudKit's default conflict resolution is simple: the last write wins. For many apps, this is fine. For apps where two family members might edit the same record within minutes of each other, it's data loss waiting to happen.
The SwiftData problem
When you use SwiftData with CloudKit (via NSPersistentCloudKitContainer under the hood), SwiftData abstracts away most of CloudKit's sync machinery. This is convenient until you need fine-grained conflict handling — SwiftData doesn't expose CloudKit's server change tokens or conflict records at the model layer.
You have two choices:
- Accept last-writer-wins and design your data model to minimize conflicts.
- Drop down to Core Data + CloudKit for the specific entities that need conflict resolution, while keeping SwiftData for everything else.
We chose option 1 for both apps, but with a deliberate data modeling strategy:
Append-only where possible
In Second Brain, memories are immutable once created. The AI-generated reflections are separate records that reference the original memory. This means two devices can create memories simultaneously and they just merge — no conflict possible.
In Fed?, each feeding event is a new record, not an update to a "last fed" timestamp. The UI derives "last fed 2 hours ago" from the most recent record rather than a mutable field. This eliminates the most common conflict scenario: two family members feeding the pet at roughly the same time.
Design principle: Prefer creating new records over updating existing ones. Append-only data models turn sync conflicts into a non-issue, because two independent inserts never conflict.
Timestamps as tiebreakers
For the few mutable fields that remain (pet profile names, user preferences), we add a modifiedAt timestamp and check it in the UI layer. If the local copy is newer, we keep it. If the remote copy is newer, we accept it. This is still "last writer wins," but it's deterministic across devices and the user can at least understand what happened.
// Simple timestamp-based merge
func mergeRemote(_ remote: PetProfile, into local: PetProfile) {
guard remote.modifiedAt > local.modifiedAt else { return }
local.name = remote.name
local.species = remote.species
local.modifiedAt = remote.modifiedAt
}
Private vs shared containers
CloudKit offers three database types: private (per-user, encrypted), shared (shared via CKShare), and public (readable by anyone). In practice, you'll use private or shared — and they behave very differently.
Private database (Second Brain)
The private database is the simplest model. Each user's data is siloed, encrypted at rest, and invisible to you as the developer. You can't see it, query it, or debug it. This is great for privacy and terrible for support.
Lessons learned:
- iCloud account switching loses data access. If a user signs into a different iCloud account, their data is still there — attached to the original account — but it's invisible to the app. Users perceive this as "the app deleted my data." We now show a clear warning when we detect an account mismatch.
- Storage quotas are real. Users with nearly-full iCloud storage will see sync silently stop. The error you get back is a
CKError.quotaExceeded, but it surfaces asynchronously and SwiftData doesn't propagate it to the UI. We poll for sync status separately. - "Optional iCloud" means twice the testing. Second Brain works with or without iCloud. This means we test every feature path with sync on and sync off — effectively two different apps. If you can require iCloud, do it. If you can't, budget the testing time.
Shared container via Family Sharing (Fed?)
Fed? lets families track pet feedings together. Rather than building a manual invitation system with CKShare, Fed? uses Apple's built-in Family Sharing with a shared private CloudKit container. When one family member taps "fed," everyone else in the family group sees the update within seconds — without any account creation, server, or invitation flow beyond Family Sharing itself.
This dramatically simplifies the participant management problem: Apple handles who's in the family group, and CloudKit handles the shared container. No share URLs, no invitation UI, no pending-acceptance states. But shared containers still come with their own constraints:
- Permissions are coarse. Everyone in the family group has equal access to the shared container. There's no "can edit feeding logs but not pet profiles" granularity — you handle any permission distinctions in application logic.
- The family organizer leaving is catastrophic. If the iCloud account that owns the family group is deleted or removed, the shared data can disappear for everyone. This is an inherent risk of any shared CloudKit container, and there's no first-party tooling to migrate ownership smoothly.
- You're coupled to Apple's family model. Family Sharing is limited to the Apple ecosystem and to Apple's definition of a "family" (up to 6 members via iCloud Family). If your app needs to share data outside this boundary — with a pet sitter, a roommate, a vet — you'd need a separate mechanism entirely.
When to use which
Use private when the data belongs to one person and should never be seen by anyone else — journals, health data, personal notes. Use shared when multiple people need to collaborate on the same data. Avoid public for user-generated content unless you're prepared to build moderation infrastructure.
Can you mix them? Yes. An app can use the private database for personal settings and the shared database for collaborative data. Just know that these are separate sync contexts — SwiftData doesn't merge them transparently. You'll manage two ModelContainer configurations or handle it at a lower level.
Practical tips from production
First sync is your first impression
The very first iCloud sync after a fresh install can take anywhere from 2 seconds to 2 minutes, depending on the data volume and network conditions. If your app shows an empty screen during this time, users will assume the app is broken or their data is lost.
We show a subtle progress indicator during the initial sync, with a "Syncing with iCloud..." label. It's not technically accurate (CloudKit doesn't give you a progress percentage), but it sets expectations. After the initial sync completes, subsequent syncs are near-instant and silent.
Batch operations need throttling
CloudKit has rate limits: 40 requests per second for the private database, with a maximum of 400 records per operation. If you're migrating a large data set or doing an initial import, you need to batch and throttle your operations. Hit the rate limit and CloudKit returns a CKError.requestRateLimited with a retryAfterSeconds value — respect it.
Test with two real devices
The Xcode simulator does not support CloudKit push notifications, which means sync triggers don't work the same way they do on physical devices. Some sync bugs only reproduce when two physical devices are logged into the same iCloud account and both running the app.
Our testing setup: two iPhones, each with a different iCloud account for shared container testing. For private container testing, both on the same account. We keep a dedicated "sync test" checklist that we run before every release:
- Create a record on device A, verify it appears on device B within 30 seconds
- Edit the same record on both devices while one is in airplane mode, then reconnect
- Delete a record on device A, verify it disappears on device B
- Uninstall and reinstall on device B, verify all data syncs back
- Sign out of iCloud on device B, sign back in, verify data reappears
NSPersistentCloudKitContainer debugging
Enable verbose CloudKit logging during development by setting the launch argument -com.apple.CoreData.CloudKitDebug 1 in your Xcode scheme. Level 1 gives you sync events; level 3 gives you every record operation. Level 3 is noisy but invaluable when something isn't syncing and you can't figure out why.
// In your Xcode scheme > Arguments > Arguments Passed On Launch:
-com.apple.CoreData.CloudKitDebug 1
Is it worth it?
Yes — with caveats.
Local-first with SwiftData and CloudKit gives you offline support, privacy, and zero server costs. Your users own their data. You don't need a backend team. These are real, material advantages.
But the developer experience is rough. The documentation is thin. The error messages are cryptic. The production schema is a one-way door. And the abstractions leak in unpredictable ways, especially around sync conflict resolution and shared containers.
If you're building a new iOS app and considering this architecture, here's my honest advice:
- Start with the private database. It's simpler. Add sharing later only if your users actually need it.
- Design your schema for immutability. Append-only data models save you from sync conflicts entirely.
- Budget 30% more time for sync-related work than you think you'll need. The sync code itself is small — the edge cases and testing are what eat your time.
- Test on real devices from day one. Simulator-only testing will give you false confidence.
The result is worth the investment. Both Second Brain and Fed? deliver the kind of instant, always-available experience that server-dependent apps can't match. When you pick up your phone on the subway, everything is just there. No spinner, no retry, no "poor connection" banner. That's what local-first is for.
Next in this series
Read next: Using Claude API in a Swift App — how we integrate the Claude API directly into Second Brain using pure URLSession, including model routing between Haiku and Sonnet, streaming responses, and keeping per-user API costs under control.