Building apps that work when the network doesn't
Users expect their mobile apps to function seamlessly whether they're in a coffee shop with strong WiFi or on a train with spotty coverage. Offline-first iOS apps: a practical guide for developers starts with a fundamental shift in how we think about data architecture—treating the local device as the source of truth rather than a fallback when the server is unavailable. This approach transforms user experience from frustrating loading states and sync failures into a genuinely responsive, resilient application. Over the next few sections, we'll walk through the patterns that keep apps functional offline while maintaining consistency when connectivity returns.
Local store as source of truth
The traditional pattern treats the server as authoritative and the client as a temporary cache. Offline-first inverts this: your local database becomes the primary store, and the server becomes a mirror that syncs changes. In iOS, SwiftData is the modern choice for this responsibility—it integrates deeply with Swift's type system and SwiftUI's reactivity, making it natural to build views that respond to local data changes without waiting for network responses.
Here's how this looks in practice. Imagine a task management app where users add, edit, and complete tasks. When a user creates a new task, it's written to SwiftData immediately. The view reads from SwiftData and displays it instantly, giving that snappy feel users expect. The network sync happens independently—fire and forget, essentially—and if it fails, the data is still safe locally.
@Model
final class Task {
var id: UUID
var title: String
var isCompleted: Bool
var createdAt: Date
var updatedAt: Date
var syncStatus: SyncStatus // pending, synced, failed
init(title: String) {
self.id = UUID()
self.title = title
self.isCompleted = false
self.createdAt = Date()
self.updatedAt = Date()
self.syncStatus = .pending
}
}
enum SyncStatus: String, Codable {
case pending
case synced
case failed
}
struct TaskListView: View {
@Query var tasks: [Task]
var body: some View {
List {
ForEach(tasks) { task in
TaskRow(task: task)
}
}
}
}
Notice the syncStatus field. This tells us whether the local change has made it to the server. We store this metadata locally so we know what still needs pushing. When the user modifies a task, we update both the task data and set syncStatus to .pending. Views automatically refresh because SwiftData notifications trigger SwiftUI updates.
The key insight: your local database is never "empty" or "loading"—it's always the current state of the app. This eliminates loading screens for reads and gives you a solid foundation for building responsive interfaces.
Sync in the background
Once the local store is the source of truth, syncing becomes a background process that pushes local changes to the server and pulls server state back down. On iOS, we typically use URLSession's background tasks or, more elegantly, use a dedicated sync engine that wakes up when the network becomes available.
The sync process runs independently of the UI. We recommend a simple pull-then-push model: first fetch any server changes (to get the latest state), then upload any pending local changes. This order helps prevent conflicts—you know what the server has before you try to push your modifications.
class SyncEngine: ObservableObject {
@Published var isSyncing = false
private let modelContext: ModelContext
private let apiClient: APIClient
func syncPendingChanges() async {
isSyncing = true
defer { isSyncing = false }
do {
// 1. Fetch server state
let serverTasks = try await apiClient.fetchTasks()
mergePull(serverTasks)
// 2. Push pending changes
let pendingTasks = try modelContext.fetch(
FetchDescriptor(predicate: #Predicate { $0.syncStatus == .pending })
)
for task in pendingTasks {
do {
_ = try await apiClient.upsertTask(task)
task.syncStatus = .synced
task.updatedAt = Date()
} catch {
task.syncStatus = .failed
}
}
try modelContext.save()
} catch {
logger.error("Sync failed: \(error)")
}
}
private func mergePull(_ serverTasks: [ServerTask]) {
// Merge server changes into local store
for serverTask in serverTasks {
if let localTask = findLocalTask(id: serverTask.id) {
if serverTask.updatedAt > localTask.updatedAt {
localTask.title = serverTask.title
localTask.isCompleted = serverTask.isCompleted
localTask.updatedAt = serverTask.updatedAt
localTask.syncStatus = .synced
}
} else {
// New task from server, insert locally
let newTask = Task(title: serverTask.title)
newTask.id = serverTask.id
newTask.updatedAt = serverTask.updatedAt
newTask.syncStatus = .synced
modelContext.insert(newTask)
}
}
}
}
Notice that sync is a separate concern from the UI. We expose isSyncing as a published property so views can show a subtle indicator if needed, but the app remains fully functional whether sync is running or not. We use a defer block to ensure isSyncing is set back to false even if an error occurs.
The sync engine should be triggered when the app comes to the foreground, when network availability changes, or on a regular timer. Use URLSessionConfiguration with waitsForConnectivity = true to ensure network requests wait for connectivity before failing.
Conflicts — Last-write-wins or server rules
When multiple devices or the same device editing offline can make changes to the same data, conflicts arise. In the code above, we use a simple last-write-wins strategy: we check the updatedAt timestamp and keep whichever version is newer. If the server's version is newer, we keep the server's version. If the local version was modified after, we use local.
This works well for many use cases, but it has a subtle risk: if the user makes a change offline, then the server receives an update from another device with a newer timestamp, our offline change gets silently overwritten during the next sync. Whether this is acceptable depends on your app's requirements.
A more conservative approach is to flag conflicts for the user to resolve:
@Model
final class Task {
var id: UUID
var title: String
var isCompleted: Bool
var createdAt: Date
var updatedAt: Date
var syncStatus: SyncStatus
var conflictState: ConflictState? // nil if no conflict
var conflictServerVersion: String?
}
enum ConflictState: String, Codable {
case userWins
case serverWins
case pending // awaiting user resolution
}
// During merge:
if let localTask = findLocalTask(id: serverTask.id) {
if serverTask.updatedAt > localTask.updatedAt {
localTask.conflictState = .pending
localTask.conflictServerVersion = serverTask.title
} else {
localTask.conflictState = .userWins
}
}
You might show a conflict resolution screen where users choose which version to keep. This is more complex but prevents silent data loss. Choose your strategy based on whether data loss is acceptable—for a notes app, last-write-wins is often fine; for a financial app, you need explicit conflict resolution.
Status — Show pending changes to users
Users need to know whether their changes are safely persisted on the server. A pending indicator reassures them that their edit will sync, while a failed state lets them retry or investigate. The syncStatus field we added earlier enables this, and it's important to surface it in the UI thoughtfully.
Rather than cluttering every row with status icons, consider a subtle approach: a small dot or icon next to the item title, and a status sheet or section at the bottom of the list showing failed items. This keeps the interface clean while still informative.
struct TaskRow: View {
let task: Task
var statusIndicator: some View {
switch task.syncStatus {
case .pending:
Image(systemName: "clock.fill")
.font(.caption)
.foregroundColor(.orange)
case .synced:
Image(systemName: "checkmark.circle.fill")
.font(.caption)
.foregroundColor(.green)
case .failed:
Image(systemName: "exclamationmark.circle.fill")
.font(.caption)
.foregroundColor(.red)
}
}
var body: some View {
HStack {
VStack(alignment: .leading) {
Text(task.title)
.strikethrough(task.isCompleted)
if task.syncStatus == .failed {
Text("Sync failed")
.font(.caption)
.foregroundColor(.red)
}
}
Spacer()
statusIndicator
}
}
}
For failed syncs, give users an explicit retry action. Rather than automatically retrying indefinitely (which can drain battery), let them tap to retry or let the system retry on next app launch or network change.
Common mistakes
- Syncing before local updates: Pushing pending changes before fetching server state can create unnecessary conflicts. Always pull first, then push.
- Not handling network interruption during sync: A request mid-flight when the network drops should not leave data in an inconsistent state. Use transactions and mark sync operations as atomic.
- Ignoring battery impact: Frequent background syncing, especially with aggressive retry logic, kills the battery. Use
waitsForConnectivityand respect the device's network conditions. - Treating local and server timestamps the same: Server time and device time can drift. Use server-assigned timestamps for conflict resolution, not device clocks.
- Over-complicating the conflict strategy: Start with last-write-wins. Add explicit resolution only if your use case demands it. Most apps don't need CRDT-level sophistication.
Key takeaways
- Make the local database (SwiftData) the source of truth; views read from it immediately, eliminating artificial loading states.
- Sync changes in the background as a separate concern, triggered by network availability or app lifecycle events.
- Choose a conflict resolution strategy appropriate to your domain—last-write-wins is simple and often sufficient; use server-assigned timestamps to avoid clock drift issues.
- Track sync status for each piece of data and surface it to users clearly without cluttering the interface.
- Test offline thoroughly: disable the network, make changes, verify they persist locally, then re-enable network and confirm sync succeeds.






Comments
Loading comments…