DEV Community

Sebastien Lato
Sebastien Lato

Posted on

SwiftUI Data Caching Strategies (Memory, Disk, Network)

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()
    }
}
Enter fullscreen mode Exit fullscreen mode

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))
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Show cached data immediately
  2. Fetch fresh data in background
  3. 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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode
func isExpired(_ cached: CachedValue<Any>) -> Bool {
    Date().timeIntervalSince(cached.timestamp) > 300
}
Enter fullscreen mode Exit fullscreen mode

Define policies:

  • 5 min for feeds
  • 1 hour for profiles
  • 24h for static data

🧬 6. Cache Key Design

Bad:

"user"
Enter fullscreen mode Exit fullscreen mode

Good:

"user_\(id)_v1"
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode
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
    }
}
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)