Offline Storage with SwiftData

In Series 1 Post 4, we built a tiered cache with memory and disk layers. That handles read-only server data well - landmarks load fast and persist across launches. But our app now has user-generated state that the cache wasn't designed for: favorites, reservations, offline edits. Data that the user creates and mutates.

The tiered cache stays for what it's good at - caching server responses. SwiftData handles everything the user creates or changes locally. Two persistence layers, each doing what they're best at.

The companion repo has the complete working code: View Code

SwiftData Models

SwiftData models look a lot like regular Swift classes with a @Model macro. We need local versions of our domain models. These are not the same as the API models or the domain models from Post 2 of Series 1 - they're a persistence layer with their own lifecycle.

import SwiftData

@Model
final class StoredLandmark {
    @Attribute(.unique) var remoteID: Int
    var name: String
    var state: String
    var city: String
    var category: String
    var isFeatured: Bool
    var isFavorite: Bool
    var imageName: String
    var latitude: Double
    var longitude: Double
    var landmarkDescription: String
    var lastSynced: Date

    init(
        remoteID: Int,
        name: String,
        state: String,
        city: String,
        category: String,
        isFeatured: Bool,
        isFavorite: Bool,
        imageName: String,
        latitude: Double,
        longitude: Double,
        landmarkDescription: String,
        lastSynced: Date = .now
    ) {
        self.remoteID = remoteID
        self.name = name
        self.state = state
        self.city = city
        self.category = category
        self.isFeatured = isFeatured
        self.isFavorite = isFavorite
        self.imageName = imageName
        self.latitude = latitude
        self.longitude = longitude
        self.landmarkDescription = landmarkDescription
        self.lastSynced = lastSynced
    }
}

The @Attribute(.unique) on remoteID is critical. When we sync from the server, we want to update existing records rather than create duplicates. SwiftData handles this with upsert behavior.

And for reservations:

@Model
final class StoredReservation {
    @Attribute(.unique) var remoteID: UUID
    var landmarkID: Int
    var date: Date
    var status: String
    var isDirty: Bool
    var lastSynced: Date

    init(
        remoteID: UUID,
        landmarkID: Int,
        date: Date,
        status: String,
        isDirty: Bool = false,
        lastSynced: Date = .now
    ) {
        self.remoteID = remoteID
        self.landmarkID = landmarkID
        self.date = date
        self.status = status
        self.isDirty = isDirty
        self.lastSynced = lastSynced
    }
}

Notice the isDirty flag. We'll use this in the next post for tracking offline changes that haven't been pushed to the server yet.

Converting Between Layers

We need to move data between the API layer, the domain layer, and the storage layer. Simple extensions handle this:

extension StoredLandmark {
    func toDomain() -> Landmark {
        Landmark(
            id: remoteID,
            name: name,
            state: state,
            city: city,
            category: Landmark.Category(rawValue: category) ?? .lakes,
            isFeatured: isFeatured,
            isFavorite: isFavorite,
            imageName: imageName,
            coordinates: Landmark.Coordinates(latitude: latitude, longitude: longitude),
            description: landmarkDescription
        )
    }
}

extension Landmark {
    func toStored() -> StoredLandmark {
        StoredLandmark(
            remoteID: id,
            name: name,
            state: state,
            city: city,
            category: category.rawValue,
            isFeatured: isFeatured,
            isFavorite: isFavorite,
            imageName: imageName,
            latitude: coordinates.latitude,
            longitude: coordinates.longitude,
            landmarkDescription: description
        )
    }
}

Setting Up the ModelContainer

Configure the container at the app level and pass it down through the environment. SwiftData needs to know about all your model types upfront:

@main
struct LandmarksApp: App {
    let container: ModelContainer

    init() {
        do {
            let schema = Schema([StoredLandmark.self, StoredReservation.self])
            let config = ModelConfiguration(isStoredInMemoryOnly: false)
            container = try ModelContainer(for: schema, configurations: [config])
        } catch {
            fatalError("Could not create ModelContainer: \(error)")
        }
    }

    var body: some Scene {
        WindowGroup {
            ContentView(authService: .live)
        }
        .modelContainer(container)
    }
}

For testing, swap isStoredInMemoryOnly to true and you get a fresh database every time.

The Offline-Capable Landmark Service

Here's where it comes together. The updated LandmarkService tries the network first, falls back to local storage, and always saves successful fetches locally:

extension LandmarkService {
    static func offlineCapable(
        authService: AuthService,
        modelContext: ModelContext
    ) -> LandmarkService {
        LandmarkService(
            fetchAll: {
                // Try network first
                do {
                    let landmarks = try await fetchFromNetwork(authService: authService)

                    // Save to local storage
                    try saveToLocal(landmarks, context: modelContext)

                    return landmarks
                } catch {
                    // Network failed, fall back to local
                    return try fetchFromLocal(context: modelContext)
                }
            }
        )
    }
}

The helper functions that do the actual work:

private func fetchFromNetwork(authService: AuthService) async throws -> [Landmark] {
    var request = URLRequest(url: URL(string: "\(API.baseURL)/landmarks")!)
    if let token = authService.loadToken() {
        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
    }
    let (data, _) = try await URLSession.shared.data(for: request)
    return try JSONDecoder().decode([LandmarkResponse].self, from: data)
        .map { $0.toDomain() }
}

private func saveToLocal(_ landmarks: [Landmark], context: ModelContext) throws {
    for landmark in landmarks {
        let stored = landmark.toStored()
        context.insert(stored)
    }
    try context.save()
}

private func fetchFromLocal(context: ModelContext) throws -> [Landmark] {
    let descriptor = FetchDescriptor<StoredLandmark>(
        sortBy: [SortDescriptor(\.name)]
    )
    let stored = try context.fetch(descriptor)
    return stored.map { $0.toDomain() }
}

Because StoredLandmark has @Attribute(.unique) on remoteID, inserting a landmark that already exists updates it instead of creating a duplicate. This is the upsert behavior we want.

Querying with Predicates

SwiftData's #Predicate macro gives you type-safe queries. Filter landmarks by category:

static func fetchByCategory(
    _ category: String,
    context: ModelContext
) throws -> [Landmark] {
    let descriptor = FetchDescriptor<StoredLandmark>(
        predicate: #Predicate { $0.category == category },
        sortBy: [SortDescriptor(\.name)]
    )
    let stored = try context.fetch(descriptor)
    return stored.map { $0.toDomain() }
}

Find favorites:

static func fetchFavorites(context: ModelContext) throws -> [Landmark] {
    let descriptor = FetchDescriptor<StoredLandmark>(
        predicate: #Predicate { $0.isFavorite },
        sortBy: [SortDescriptor(\.name)]
    )
    let stored = try context.fetch(descriptor)
    return stored.map { $0.toDomain() }
}

Toggling Favorites Offline

Here's a real offline-first feature. When the user toggles a favorite, update the local database immediately. The sync engine (next post) will push the change to the server later.

extension LandmarkService {
    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()
        try context.save()
    }
}

The UI responds instantly because SwiftData triggers SwiftUI observation. The user doesn't wait for a round trip to the server.

Using @Query in Views

For views that display stored data directly, @Query is the simplest path. It automatically observes changes and re-renders:

struct FavoritesView: View {
    @Query(
        filter: #Predicate<StoredLandmark> { $0.isFavorite },
        sort: \.name
    )
    private var favorites: [StoredLandmark]

    var body: some View {
        List(favorites) { stored in
            LandmarkRow(landmark: stored.toDomain())
        }
        .overlay {
            if favorites.isEmpty {
                ContentUnavailableView(
                    "No Favorites",
                    systemImage: "heart",
                    description: Text("Landmarks you favorite will appear here.")
                )
            }
        }
    }
}

Showing Freshness

Users should know when they're looking at stale data. Add a subtle indicator when the data hasn't been synced recently:

struct SyncStatusView: View {
    let lastSynced: Date?

    var body: some View {
        if let lastSynced {
            let minutes = Int(-lastSynced.timeIntervalSinceNow / 60)
            if minutes > 30 {
                Label(
                    "Updated \(minutes) min ago",
                    systemImage: "icloud.slash"
                )
                .font(.caption)
                .foregroundStyle(.secondary)
            }
        }
    }
}

Testing

Testing offline behavior is easy because you can create an in-memory ModelContainer and inject it:

@Test func fallsBackToLocalWhenNetworkFails() async throws {
    let config = ModelConfiguration(isStoredInMemoryOnly: true)
    let container = try ModelContainer(
        for: StoredLandmark.self,
        configurations: [config]
    )
    let context = ModelContext(container)

    // Pre-populate local storage
    let stored = StoredLandmark(
        remoteID: 1, name: "Half Dome", state: "California",
        city: "Yosemite", category: "mountains", isFeatured: true,
        isFavorite: false, imageName: "halfdome",
        latitude: 37.7459, longitude: -119.5332,
        landmarkDescription: "A granite dome"
    )
    context.insert(stored)
    try context.save()

    // Create a service that fails on network
    let service = LandmarkService(
        fetchAll: {
            return try fetchFromLocal(context: context)
        }
    )

    let landmarks = try await service.fetchAll()
    #expect(landmarks.count == 1)
    #expect(landmarks[0].name == "Half Dome")
}

What's Next

The app now works offline, but we have a new problem. If the user makes changes while offline (toggling favorites, creating reservations), those changes are stuck on the device. In the next post, we'll build a sync engine that pushes local changes to the server in the background and handles what happens when both sides have been modified.