Local-first iOS with SwiftData and CloudKit: What Nobody Tells You

Production lessons from shipping Second Brain and Fed? — two apps that chose local-first for the right reasons and then hit every undocumented wall Apple had waiting.

Series: Local-first + AI + Privacy on iOS

  1. Local-first iOS with SwiftData and CloudKit (this post)
  2. Using Claude API in a Swift app: model routing, streaming, and cost control
  3. Zero-SDK iOS analytics: shipping without Firebase, Mixpanel, or Sentry

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

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:

  1. 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).
  2. Always deploy to the development container first. You can reset the development schema. You cannot reset production.
  3. 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.
  4. Version your models explicitly. We use schemaVersion integer 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:

  1. Accept last-writer-wins and design your data model to minimize conflicts.
  2. 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:

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:

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:

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:

  1. Start with the private database. It's simpler. Add sharing later only if your users actually need it.
  2. Design your schema for immutability. Append-only data models save you from sync conflicts entirely.
  3. 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.
  4. 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.