Background Sync and Conflict Resolution
In the previous post, we added SwiftData to the Landmarks app so it works offline. Users can browse landmarks and toggle favorites without a network connection. But those changes are trapped on the device. The server doesn't know about them, and if the server has newer data, the app doesn't know about that either.
This is the hard part of offline-first: synchronization. This post builds a sync engine that pushes dirty local changes to the server, pulls fresh data back down, and handles what happens when both sides have been modified at the same time.
The companion repo has the complete working code: View Code
Dirty Tracking
In the previous post, we added an isDirty flag to StoredReservation. Let's formalize that pattern. When the user makes a change locally, we mark the record as dirty. When the sync engine successfully pushes it to the server, we clear the flag.
extension StoredReservation {
func markDirty() {
isDirty = true
}
func markClean(lastSynced: Date = .now) {
isDirty = false
self.lastSynced = lastSynced
}
}
For favorites, we need a similar mechanism. Add a dirty flag to StoredLandmark:
// Add to StoredLandmark
var isFavoriteDirty: Bool = false
When the user toggles a favorite offline:
static func toggleFavoriteLocally(
landmarkID: Int,
context: ModelContext
) throws {
let descriptor = FetchDescriptor<StoredLandmark>(
predicate: #Predicate { $0.remoteID == landmarkID }
)
guard let stored = try context.fetch(descriptor).first else { return }
stored.isFavorite.toggle()
stored.isFavoriteDirty = true
try context.save()
}
The Sync Engine
The sync engine is a plain Swift class. It doesn't use Combine, SwiftUI, or any framework-specific tools. It just takes a ModelContext and an auth token and does its work.
actor SyncEngine {
private let modelContainer: ModelContainer
private let authService: AuthService
init(modelContainer: ModelContainer, authService: AuthService) {
self.modelContainer = modelContainer
self.authService = authService
}
func sync() async throws {
let context = ModelContext(modelContainer)
// Push local changes first, then pull remote
try await pushDirtyFavorites(context: context)
try await pushDirtyReservations(context: context)
try await pullLandmarks(context: context)
try await pullReservations(context: context)
try context.save()
}
}
The order matters. Push first, then pull. If you pull first, you might overwrite local changes that haven't been sent to the server yet.
Pushing Dirty Records
Find everything marked dirty and send it to the server:
extension SyncEngine {
private func pushDirtyFavorites(context: ModelContext) async throws {
let descriptor = FetchDescriptor<StoredLandmark>(
predicate: #Predicate { $0.isFavoriteDirty }
)
let dirtyLandmarks = try context.fetch(descriptor)
guard let token = authService.loadToken() else { return }
for landmark in dirtyLandmarks {
do {
try await updateFavoriteOnServer(
landmarkID: landmark.remoteID,
isFavorite: landmark.isFavorite,
token: token
)
landmark.isFavoriteDirty = false
landmark.lastSynced = .now
} catch {
// Leave dirty, will retry next sync
continue
}
}
}
private func pushDirtyReservations(context: ModelContext) async throws {
let descriptor = FetchDescriptor<StoredReservation>(
predicate: #Predicate { $0.isDirty }
)
let dirtyReservations = try context.fetch(descriptor)
guard let token = authService.loadToken() else { return }
for reservation in dirtyReservations {
do {
try await pushReservationToServer(reservation, token: token)
reservation.markClean()
} catch let error as SyncConflict {
// Server has a different version - handle conflict
try await resolveConflict(
local: reservation,
serverVersion: error.serverVersion,
context: context
)
} catch {
continue
}
}
}
}
Pulling Remote Data
Pull fresh data from the server and merge it into local storage:
extension SyncEngine {
private func pullLandmarks(context: ModelContext) async throws {
guard let token = authService.loadToken() else { return }
let remote = try await fetchLandmarksFromServer(token: token)
for landmark in remote {
let stored = landmark.toStored()
// Check if we have a dirty local version
let id = landmark.id
let descriptor = FetchDescriptor<StoredLandmark>(
predicate: #Predicate { $0.remoteID == id }
)
if let existing = try context.fetch(descriptor).first {
// Don't overwrite dirty local changes
if !existing.isFavoriteDirty {
existing.isFavorite = landmark.isFavorite
}
existing.name = landmark.name
existing.state = landmark.state
existing.landmarkDescription = landmark.description
existing.lastSynced = .now
} else {
context.insert(stored)
}
}
}
}
The key line: if !existing.isFavoriteDirty. We never overwrite a local change that hasn't been pushed yet. The user's intent takes priority until the server has acknowledged it.
Conflict Resolution
What happens when both the local device and the server have changed the same reservation? You have three options:
- Last-write-wins: The most recent change takes priority. Simple but can lose data.
- Server-wins: Always defer to the server. Safe but frustrating for users.
- Ask the user: Present both versions and let them choose.
For reservations (where the user has real intent), we ask. For favorites (low stakes), we use last-write-wins based on timestamps.
Detecting Conflicts
On the server side, include a lastModified timestamp with every response. When pushing a change, send the local lastSynced timestamp. If the server version is newer than what the client last saw, that's a conflict:
struct SyncConflict: Error {
let serverVersion: ReservationResponse
}
private func pushReservationToServer(
_ reservation: StoredReservation,
token: String
) async throws {
var request = URLRequest(url: URL(string: "\(API.baseURL)/reservations/\(reservation.remoteID)")!)
request.httpMethod = "PUT"
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body = ReservationUpdate(
date: reservation.date,
status: reservation.status,
lastSynced: reservation.lastSynced
)
request.httpBody = try JSONEncoder().encode(body)
let (data, response) = try await URLSession.shared.data(for: request)
let http = response as! HTTPURLResponse
if http.statusCode == 409 {
let serverVersion = try JSONDecoder().decode(ReservationResponse.self, from: data)
throw SyncConflict(serverVersion: serverVersion)
}
guard http.statusCode == 200 else {
throw SyncError.pushFailed
}
}
Presenting Conflicts to the User
Store unresolved conflicts so the UI can display them:
@Model
final class SyncConflictRecord {
@Attribute(.unique) var reservationID: UUID
var localDate: Date
var localStatus: String
var serverDate: Date
var serverStatus: String
var createdAt: Date
init(
reservationID: UUID,
localDate: Date,
localStatus: String,
serverDate: Date,
serverStatus: String
) {
self.reservationID = reservationID
self.localDate = localDate
self.localStatus = localStatus
self.serverDate = serverDate
self.serverStatus = serverStatus
self.createdAt = .now
}
}
The conflict resolution UI:
struct ConflictResolutionView: View {
@Query var conflicts: [SyncConflictRecord]
@Environment(\.modelContext) private var context
var body: some View {
List(conflicts) { conflict in
VStack(alignment: .leading, spacing: 12) {
Text("Reservation Conflict")
.font(.headline)
HStack(spacing: 24) {
VStack(alignment: .leading) {
Text("Your Version")
.font(.caption.bold())
Text(conflict.localDate, style: .date)
Text(conflict.localStatus)
.foregroundStyle(.secondary)
}
VStack(alignment: .leading) {
Text("Server Version")
.font(.caption.bold())
Text(conflict.serverDate, style: .date)
Text(conflict.serverStatus)
.foregroundStyle(.secondary)
}
}
HStack {
Button("Keep Mine") {
resolveConflict(conflict, keepLocal: true)
}
.buttonStyle(.borderedProminent)
Button("Use Server") {
resolveConflict(conflict, keepLocal: false)
}
.buttonStyle(.bordered)
}
}
}
.navigationTitle("Sync Conflicts")
.overlay {
if conflicts.isEmpty {
ContentUnavailableView(
"No Conflicts",
systemImage: "checkmark.circle",
description: Text("Everything is in sync.")
)
}
}
}
private func resolveConflict(_ conflict: SyncConflictRecord, keepLocal: Bool) {
let id = conflict.reservationID
let descriptor = FetchDescriptor<StoredReservation>(
predicate: #Predicate { $0.remoteID == id }
)
guard let reservation = try? context.fetch(descriptor).first else { return }
if keepLocal {
reservation.markDirty()
} else {
reservation.date = conflict.serverDate
reservation.status = conflict.serverStatus
reservation.markClean()
}
context.delete(conflict)
try? context.save()
}
}
Background Sync with BGTaskScheduler
The sync engine shouldn't only run when the app is open. Register a background task so iOS can sync periodically:
import BackgroundTasks
enum BackgroundSync {
static let taskIdentifier = "com.kylebrowning.landmarks.sync"
static func register(
modelContainer: ModelContainer,
authService: AuthService
) {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: taskIdentifier,
using: nil
) { task in
guard let task = task as? BGAppRefreshTask else { return }
handleSync(task: task, modelContainer: modelContainer, authService: authService)
}
}
static func schedule() {
let request = BGAppRefreshTaskRequest(identifier: taskIdentifier)
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 minutes
try? BGTaskScheduler.shared.submit(request)
}
private static func handleSync(
task: BGAppRefreshTask,
modelContainer: ModelContainer,
authService: AuthService
) {
let syncTask = Task {
let engine = SyncEngine(
modelContainer: modelContainer,
authService: authService
)
try await engine.sync()
}
task.expirationHandler = {
syncTask.cancel()
}
Task {
do {
try await syncTask.value
task.setTaskCompleted(success: true)
} catch {
task.setTaskCompleted(success: false)
}
// Schedule the next sync
schedule()
}
}
}
Register in your App.init and schedule when the app moves to the background:
@main
struct LandmarksApp: App {
@Environment(\.scenePhase) private var scenePhase
init() {
BackgroundSync.register(
modelContainer: container,
authService: .live
)
}
var body: some Scene {
WindowGroup {
ContentView(authService: .live)
}
.modelContainer(container)
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .background {
BackgroundSync.schedule()
}
}
}
}
Don't forget to add the background task identifier to your Info.plist under BGTaskSchedulerPermittedIdentifiers.
Testing the Sync Engine
Because the sync engine is an actor with injected dependencies, testing it directly is clean:
@Test func pushDirtyFavoriteClearsFlag() async throws {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(
for: StoredLandmark.self, StoredReservation.self, SyncConflictRecord.self,
configurations: [config]
)
let context = ModelContext(container)
let landmark = StoredLandmark(
remoteID: 1, name: "Half Dome", state: "California",
city: "Yosemite", category: "mountains", isFeatured: true,
isFavorite: true, imageName: "halfdome",
latitude: 37.7459, longitude: -119.5332,
landmarkDescription: "A granite dome"
)
landmark.isFavoriteDirty = true
context.insert(landmark)
try context.save()
let engine = SyncEngine(
modelContainer: container,
authService: .mockAuthenticated
)
try await engine.sync()
let fetched = try context.fetch(FetchDescriptor<StoredLandmark>())
#expect(fetched[0].isFavoriteDirty == false)
}
What's Next
The app now works offline and syncs in the background. Users can make changes without a network connection and trust that everything will be reconciled. But how do we know it's all working in production? In the next post, we'll add structured logging and health checks to the Vapor backend so we can observe the system when it's running in the real world.