Once your SwiftUI app hits real users, caching stops being optional.
Without it, you get:
- slow screens
- repeated network calls
- battery drain
- poor offline behavior
- janky lists
- angry users
But βjust add a cacheβ is not an architecture.
This post shows how to design a proper multi-layer caching system in SwiftUI:
- memory cache
- disk cache
- network cache
- invalidation rules
- refresh strategies
All clean. All testable. All production-grade.
π§ The Core Principle
Caching is not storage.
Caching is a performance strategy.
Each layer has a different purpose:
- Memory β speed
- Disk β persistence
- Network β freshness
You need all three, intentionally.
π§± The 3 Cache Layers
UI
β
Memory Cache
β
Disk Cache
β
Network
Reads go top β down
Writes go bottom β up
π§ 1. Memory Cache (Fastest, Volatile)
Use for:
- lists
- detail screens
- session data
- frequently accessed objects
Simple example
final class MemoryCache<Key: Hashable, Value> {
private var storage: [Key: Value] = [:]
func get(_ key: Key) -> Value? {
storage[key]
}
func set(_ key: Key, value: Value) {
storage[key] = value
}
func clear() {
storage.removeAll()
}
}
Characteristics:
- lightning fast
- lost on app kill
- small footprint
- no I/O cost
πΎ 2. Disk Cache (Persistent, Slower)
Use for:
- API responses
- images
- offline data
- large payloads
Example
final class DiskCache {
private let directory = FileManager.default.urls(
for: .cachesDirectory,
in: .userDomainMask
).first!
func url(for key: String) -> URL {
directory.appendingPathComponent(key)
}
func write(_ data: Data, for key: String) throws {
try data.write(to: url(for: key))
}
func read(for key: String) throws -> Data {
try Data(contentsOf: url(for: key))
}
}
Characteristics:
- survives restarts
- slower than memory
- must be managed
- needs cleanup policy
π 3. Network Cache (Freshness Layer)
This is either:
- URLCache
- custom HTTP cache
- API-level cache
Using URLCache
let cache = URLCache(
memoryCapacity: 50 * 1024 * 1024,
diskCapacity: 200 * 1024 * 1024
)
URLCache.shared = cache
Use when:
- server supports cache headers
- responses are deterministic
Avoid when:
- auth tokens change
- personalized data
- highly dynamic responses
π 4. Stale-While-Revalidate Pattern
This is the gold standard UX pattern:
- Show cached data immediately
- Fetch fresh data in background
- Update UI when done
func load() async {
if let cached = cache.get(key) {
state = cached
}
let fresh = try await api.fetch()
cache.set(key, value: fresh)
state = fresh
}
User gets:
- instant UI
- fresh data
- no flicker
β³ 5. Cache Expiration Strategy
Never let cache live forever.
Example:
struct CachedValue<T> {
let value: T
let timestamp: Date
}
func isExpired(_ cached: CachedValue<Any>) -> Bool {
Date().timeIntervalSince(cached.timestamp) > 300
}
Define policies:
- 5 min for feeds
- 1 hour for profiles
- 24h for static data
𧬠6. Cache Key Design
Bad:
"user"
Good:
"user_\(id)_v1"
Keys must include:
- entity id
- version
- context
This avoids:
- collisions
- stale bugs
- silent corruption
π§± 7. Repository Pattern (Where Cache Lives)
Never access cache from views.
protocol UserRepository {
func getUser(id: String) async throws -> User
}
final class UserRepositoryImpl: UserRepository {
let memory: MemoryCache<String, User>
let disk: DiskCache
let api: APIClient
func getUser(id: String) async throws -> User {
if let user = memory.get(id) {
return user
}
if let data = try? disk.read(for: id),
let user = try? JSONDecoder().decode(User.self, from: data) {
memory.set(id, value: user)
return user
}
let user = try await api.fetchUser(id: id)
memory.set(id, value: user)
try? disk.write(try JSONEncoder().encode(user), for: id)
return user
}
}
Views never know caching exists.
They just ask for data.
π 8. Invalidation Rules
Always define:
- when cache is invalid
- when to refresh
- when to purge
Examples:
- logout β clear all
- user switch β clear user scope
- app update β bump cache version
- feature flag change β invalidate feature data
π§ͺ 9. Testing Becomes Simple
Because cache is isolated:
func testReturnsCachedUser() async {
let repo = UserRepositoryImpl(
memory: mockMemory,
disk: mockDisk,
api: mockAPI
)
let user = try await repo.getUser(id: "1")
XCTAssertEqual(user.id, "1")
}
No UI involved.
β 10. Common Anti-Patterns
Avoid:
- caching inside views
- single global cache for everything
- never expiring data
- mixing network logic & cache logic
- relying only on URLCache
- storing models directly in UserDefaults
π§ Mental Model
Think in layers:
View
β ViewModel
β Repository
β Memory Cache
β Disk Cache
β Network
Each layer has one responsibility.
π Final Thoughts
A good cache system gives you:
- faster UI
- better battery life
- offline support
- fewer bugs
- happier users
- happier backend
Once you design caching properly, half your performance problems disappear.
Top comments (0)