Offline-First Caching with UserDefaults as a Sync Buffer
One of the most common performance pitfalls in iOS applications is tying the initial user interface to a network request. Even with fast APIs, the combination of application startup, authentication, and data loading can leave users staring at an empty screen for several seconds. In our Music Theory Learning app we use a better approach and separate perceived responsiveness from data freshness by adopting a cache-first loading strategy.
In SwiftUI, this pattern fits naturally with a reactive architecture. The application restores the most recently persisted state from a lightweight local cache, allowing the view hierarchy to render immediately. At the same time, an asynchronous task refreshes the data from the backend and publishes updates when they become available. From the user's perspective, the application feels responsive from the very first frame, while still converging on the latest server state in the background.
Beyond improving startup performance, a local cache also makes the application more resilient. Temporary network outages or backend maintenance no longer prevent users from accessing previously synchronized data. Instead of treating offline operation as a special case, the cache becomes an integral part of the data layer, enabling the application to continue functioning while synchronization is retried transparently. This pattern is simple to implement, scales well as an application grows, and has become a common building block of modern iOS architecture.
class TeacherDashboardViewModel: ObservableObject {
@Published var students: [Student] = []
init() {
// Eagerly populate from offline cache so the view never flashes an empty state on first render
if let cachedStudents = SupabaseOfflineStoreManager.shared.loadEnrolledStudents() {
students = cachedStudents
}
}
func reloadAllData() async {
await loadStudentsRemote()
}
func loadStudentsRemote() async {
do {
try await enrollmentService.fetchEnrolledStudents()
if let cachedStudents = SupabaseOfflineStoreManager.shared.loadEnrolledStudents() {
students = cachedStudents
}
} catch {
logger.debug("\(#function): failed to load students from remote; error: \(error.localizedDescription)")
}
}
}
class StudentEnrollmentService {
func fetchEnrolledStudents() async throws {
do {
// Simplified fetch query to read enrolled students
let response: [Student] = try await supabaseService.client
.from("students")
.execute()
.value
// Save to offline store
SupabaseOfflineStoreManager.shared.saveEnrolledStudents(response)
} catch {
throw EnrollmentError.networkError
}
}
}
// Simplified offline store handling
class SupabaseOfflineStoreManager {
var cachedEnrolledStudents: [Student]? = nil
func saveEnrolledStudents(_ students: [Student]) {
let cached = CachedEnrolledStudents(
students: students,
savedAt: Date()
)
if let encoded = try? JSONEncoder().encode(cached) {
UserDefaults.standard.set(encoded, forKey: cachedEnrolledStudentsKey)
}
cachedEnrolledStudents = students
}
func loadEnrolledStudents() -> [Student]? {
if
let data = UserDefaults.standard.data(forKey: cachedEnrolledStudentsKey),
let cached = try? JSONDecoder().decode(CachedEnrolledStudents.self, from: data)
{
cachedEnrolledStudents = cached.students
return cached.students
}
return nil
}
}
