Local-First iOS Architecture: Building Apps That Work Offline
Not offline-capable — offline-first. The design premise that changes your data model, conflict resolution strategy, and sync architecture from day one. With production patterns for SwiftData, Core Data, and NSPersistentCloudKitContainer.
Offline-capable vs offline-first: a different premise
Most iOS apps that "support offline mode" are designed as offline-capable: the server is the source of truth, the local store is a cache, and the app degrades gracefully when the network is unavailable. This works with reliable connectivity. It fails when the user is on a plane, in a basement, or in any situation where the network is intermittently available for hours rather than seconds.
Offline-first inverts the premise: the local store is the source of truth. All reads and writes go to local persistence always — not as a fallback. Sync to the cloud is a background process, not a precondition for functionality. The user never sees a spinner for a basic read or write.
Offline-capable (avoid)
- • Reads go to server first, cache on success
- • Writes fail if offline — queued for retry
- • Source of truth: server
- • Conflict resolution: rare — server always wins
- • Sync state: hidden from the user
Offline-first (recommended)
- • Reads always go to local store — instant
- • Writes go to local store first, sync queued
- • Source of truth: local store
- • Conflict resolution: designed explicitly
- • Sync state: visible and honest
Data model design for sync
A data model designed for local-only use is often not sync-ready. There are three properties every syncable entity needs that are easy to overlook when designing for single-device use.
// ✅ Sync-ready SwiftData model
@Model
final class Entry {
// 1. Stable UUID — not a database-generated Int ID
// Must be stable across devices and after deletion/recreation
var id: UUID = UUID()
// 2. User-visible content
var title: String = ""
var body: String = ""
// 3. Modification timestamp — used for conflict resolution
// Update this on every write, not just on creation
var modifiedAt: Date = Date.now
// 4. Soft delete — don't physically delete records before sync
// CloudKit cannot sync a deletion to a device that never received the record
var isDeleted: Bool = false
var deletedAt: Date? = nil
// 5. Sync state — track per-record, not globally
@Transient var syncStatus: SyncStatus = .unknown
enum SyncStatus {
case unknown, pending, synced, conflict
}
}
// ❌ Not sync-ready
@Model
final class BadEntry {
// Auto-incremented Int ID — not stable across devices
// @Attribute(.primaryKey) var id: Int
- ▸ Always use `UUID` as the primary key for syncable entities. CloudKit uses the record ID as the stable reference across devices.
- ▸ Soft deletes prevent sync gaps where a deletion in CloudKit arrives before the original creation on a newly-restored device.
- ▸ Always update `modifiedAt` on every write — this is the tie-breaker in last-write-wins conflict resolution.
## NSPersistentCloudKitContainer: production setup
`NSPersistentCloudKitContainer` handles sync in the background with minimal code. The non-obvious parts are the merge policy, notification handling, and CloudKit account state management.
```swift
import CoreData
class PersistenceController {
static let shared = PersistenceController()
let container: NSPersistentCloudKitContainer
init() {
container = NSPersistentCloudKitContainer(name: "MyApp")
// Enable remote change notifications — needed to detect
// CloudKit changes when app is in the background
guard let description = container.persistentStoreDescriptions.first else {
fatalError("No persistent store description found")
}
description.setOption(true as NSNumber,
forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
description.setOption(true as NSNumber,
forKey: NSPersistentHistoryTrackingKey)
container.loadPersistentStores { _, error in
if let error
}
// NSMergeByPropertyObjectTrumpMergePolicy:
// In-memory changes win over persistent store changes.
// Correct for most local-first patterns.
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
container.viewContext.automaticallyMergesChangesFromParent = true
}
}
// Listen for CloudKit sync events
class SyncMonitor: ObservableObject {
@Published var lastSyncDate: Date?
@Published var syncFailed = false
init() {
NotificationCenter.default.addObserver(
self,
selector: #selector(handleCloudKitEvent(_:)),
name: NSPersistentCloudKitContainer.eventChangedNotification,
object: nil
)
}
@objc private func handleCloudKitEvent(_ notification: Notification) {
guard let event = notification.userInfo?[
NSPersistentCloudKitContainer.eventNotificationUserInfoKey
] as? NSPersistentCloudKitContainer.Event else
DispatchQueue.main.async {
if event.type == .import && event.succeeded {
self.lastSyncDate = Date()
self.syncFailed = false
} else if !event.succeeded {
self.syncFailed = true
}
}
}
}
Conflict resolution strategies
Most local-first apps can use last-write-wins. Some data types require something more sophisticated. Choose the strategy before writing the data model.
| Strategy | When | Impl | Tradeoff | | --- | --- | --- | --- | | Last-write-wins (default) | Notes, settings, profiles — any data where the most recent edit is always correct | NSMergeByPropertyObjectTrumpMergePolicy + modifiedAt timestamp. One device's write overwrites another's if it is newer. | Loses data if two devices make different edits to the same field while offline. Acceptable for most single-user apps. | | Append-only / event log | Ledger data, activity logs, chat messages — any data where all writes are individually meaningful | Each write creates a new record with a UUID and timestamp. Never update or delete records. Derive current state by reading all events in order. | Storage grows unboundedly without compaction. Compaction requires coordination. Most appropriate for financial data. | | CRDT (Conflict-free Replicated Data Type) | Collaborative editing, counters, sets — data with mathematically merge-able operations | Design the data type so any two diverged states can be merged deterministically. Counters, grow-only sets, and LWW-element-sets are common examples. | Complex to implement correctly. Overkill for most iOS apps. Consider before Figma-style collaborative apps. |
Sync state UI: be honest with users
Most apps hide sync state entirely, leaving users uncertain whether their data is persisted to iCloud. A small sync indicator — not intrusive, not alarming — builds trust.
struct SyncStatusView: View {
@Environment(SyncMonitor.self) private var sync;
var body: some View {
HStack(spacing: 6) {
if sync.syncFailed {
Image(systemName: "exclamationmark.icloud")
.foregroundStyle(.orange)
Text("Sync paused")
.font(.caption2)
.foregroundStyle(.secondary)
} else if let date = sync.lastSyncDate {
Image(systemName: "checkmark.icloud")
.foregroundStyle(.green)
Text("Synced \\(date.formatted(.relative(presentation: .numeric)))")
.font(.caption2)
.foregroundStyle(.secondary)
} else {
// Never synced — either offline or first launch
Image(systemName: "icloud")
.foregroundStyle(.secondary)
}
}
}
}
// Always handle CloudKit account unavailability gracefully
// The app MUST work without iCloud — don't make sync a hard dependency
struct RootView: View {
@State private var cloudStatus: CKAccountStatus = .couldNotDetermine
var body: some View {
ContentView()
.task {
cloudStatus = try? await CKContainer.default().accountStatus() ?? .noAccount
}
.overlay(alignment: .top) {
if cloudStatus == .noAccount {
Text("iCloud unavailable — data saved locally")
.font(.caption2)
.foregroundStyle(.secondary)
.padding(4)
}
}
}
}
Frequently asked questions
What is the difference between offline-capable and offline-first? Offline-capable: the server is the source of truth, the local cache is a degraded fallback. Offline-first: the local store is always the source of truth, sync is a background concern. The distinction changes the data model, conflict resolution strategy, and UX.
How does NSPersistentCloudKitContainer handle conflicts? Last-write-wins by default, controlled by the merge policy. NSMergeByPropertyObjectTrumpMergePolicy is the most common correct choice — in-memory changes beat persistent store changes. For collaborative data where both writes matter, you need a custom conflict resolution strategy.
Does NSPersistentCloudKitContainer work in the simulator? Only with a real iCloud account configured on the simulator. In practice, test CloudKit sync on real devices. The simulator's CloudKit container uses the development environment and is less reliable for sync testing.
SwiftData or Core Data for a new local-first app? iOS 18+: SwiftData is stable enough. iOS 17 targets: Core Data is safer — SwiftData has known bugs with composite predicates and some relationship queries. Both use NSPersistentCloudKitContainer under the hood for CloudKit sync.
Authoritative References
- NSPersistentCloudKitContainer — Apple Documentation
- SwiftData Documentation
- CloudKit Documentation
- WWDC 2019 — Designing for Local First
- WWDC 2023 — Dive deeper into SwiftData
Related
- SwiftData vs Core Data in 2026: A Production Decision Guide
- NSPersistentCloudKitContainer: What Apple's Docs Don't Tell You
- How to Build an Offline-First iOS App: An Architecture Guide
- AI-Native iOS Architecture: On-Device Intelligence Without the Cloud
- HealthKit Integration: Architecture Patterns for Health Data Dashboards
- Architecture Patterns Reference →