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.