Insights / iOS Architecture
Local-First iOS Architecture
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 <- don't do this
var name: String = ""
// No modification timestamp — conflict resolution is guesswork
// No soft delete — deletions may be lost during sync gaps
}- ▸Always use
UUIDas 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
modifiedAton 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.
import CoreData
import CloudKit
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 { fatalError("CoreData load failed: \(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 { return }
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.
Last-write-wins (default)
Append-only / event log
CRDT (Conflict-free Replicated Data Type)
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)
}
}
}
}