Question 3
Actor design in a networking and cache stack
You're building a networking layer with caching. Where would you introduce actors, and how would you structure them to avoid contention or bottlenecks?
Follow-ups
- When can actors become a performance problem?
Answer outline
Use an actor to protect the cache state, and leave the rest of the networking layer alone. URLSession already handles async waiting safely. The part that can race is your own shared bookkeeping: the cache map, its expiry policy, and any tracking of duplicate in-flight requests.
Split the responsibilities so one actor owns only the cache map and its policies, such as time to live (TTL) and a maximum entry count. Network calls stay as plain async functions on a non-actor client that awaits URLSession. A slow download then suspends outside the cache actor instead of holding up every cache read.
Contention shows up when every read and write funnels through one hot actor. It gets worse when you do heavy work such as JSON or image decoding inside it. Keep actors small, decode after you leave the actor, and use nonisolated helpers for pure functions.
Principles
- Each actor has one serial executor, so keep its critical sections short and do long I/O outside it.
- Reentrancy means another task may change the cache while you're suspended at an
await, so re-check before you write. - Keep heavy decode work out of the actor, because a slow decode inside it serializes every other caller.
- If one actor still runs hot, split it: memory cache, disk cache, and in-flight request tracking can each be their own actor.
actor HTTPCache {
private var entries: [URL: (data: Data, expiry: Date)] = [:]
func cachedResponse(for url: URL) -> Data? {
guard let entry = entries[url], entry.expiry > Date() else { return nil }
return entry.data
}
func store(_ data: Data, for url: URL, ttl: TimeInterval) {
entries[url] = (data, Date().addingTimeInterval(ttl))
}
}
struct APIClient {
let session: URLSession
let cache: HTTPCache
func data(from url: URL) async throws -> Data {
if let hit = await cache.cachedResponse(for: url) { return hit }
let (data, _) = try await session.data(from: url)
await cache.store(data, for: url, ttl: 300)
return data
}
}
// Problem: image decoding inside the actor blocks every other cache user
actor BadImageCache {
func image(for url: URL) async throws -> UIImage {
let data = try await download(url)
return UIImage(data: data)! // expensive, and it serializes everyone
}
}
// Fix: the actor stores Data or file URLs. Decode after leaving the actor,
// inside an async let or a task group owned by the caller.
Follow-up angles
- A disk cache backed by
FileManageror SQLite usually gets its own actor or serial queue so the network-to-memory path stays fast. Watch for same-file races across processes too, such as an app extension writing to the same container.



