
Image caching in SwiftUI
Learn how to cache images in memory when working with a SwiftUI app.
When you build an iOS app that displays images from the internet, whether it's user profile pictures, product photos, or social media content, you may notice a frustrating pattern: images reload every time a user navigates back to a screen. This results in longer loading times, increased mobile data usage, and a slower overall experience.
The solution to this problem is image caching, a technique that stores downloaded images locally so they can be displayed instantly on subsequent requests.
Caching is simply saving data in a temporary location so you don’t have to fetch it again and again. In the case of images, for instance:
- Without caching, every time you need a photo, you ask the server again.
- With caching, once you download it the first time, you keep a copy and reuse it.
There are two main types of caching:
- Memory cache: stores images in the device’s RAM. It guarantees very fast access, but this is temporary; if the app is closed or the system clears memory, the cache is gone.
- Disk cache: stores images on the device’s storage. It's slower than memory, but persistent and survives app restarts.
For now, we’ll focus on a simple memory cache using NSCache
, which is enough to see the benefits right away.
SwiftUI introduced AsyncImage
in iOS 15. It lets you load remote images without blocking the UI:
AsyncImage(url: URL(string: "https://example.com/image.jpg")) { image in
image
.resizable()
.aspectRatio(contentMode: .fit)
} placeholder: {
ProgressView()
}
.frame(width: 100, height: 100)
This works fine, but there’s a catch: AsyncImage
doesn’t cache images between screen loads. So if the same image scrolls off-screen and comes back, SwiftUI may fetch it again from the network. That’s where caching comes in.
Let's create a memory cache using NSCache
, which is Apple's thread-safe caching solution that automatically handles memory pressure:
import SwiftUI
import UIKit
class ImageCache {
static let shared = NSCache<NSURL, UIImage>()
init() {
// Configure cache limits
ImageCache.shared.countLimit = 100 // Maximum 100 images
ImageCache.shared.totalCostLimit = 50 * 1024 * 1024 // 50MB limit
}
}
Using NSCache
is helpful because it’s thread-safe, it automatically removes items when the system is low on memory, and allows you to set limits on how many items it should store or how much memory it should use.
Now, let's build a wrapper around AsyncImage
that adds caching functionality:
struct CachedAsyncImage<Content: View, Placeholder: View>: View {
let url: URL
let scale: CGFloat
let content: (Image) -> Content
let placeholder: () -> Placeholder
@State private var cachedImage: UIImage?
init(
url: URL,
scale: CGFloat = 1.0,
@ViewBuilder content: @escaping (Image) -> Content,
@ViewBuilder placeholder: @escaping () -> Placeholder
) {
self.url = url
self.scale = scale
self.content = content
self.placeholder = placeholder
}
var body: some View {
Group {
if let cachedImage {
content(Image(uiImage: cachedImage))
} else {
AsyncImage(url: url, scale: scale) { phase -> AnyView in
switch phase {
case .success(let image):
// Save to cache when AsyncImage successfully loads
saveToCache(from: url)
return AnyView(content(image))
case .failure(_):
return AnyView(placeholder())
case .empty:
return AnyView(placeholder())
@unknown default:
return AnyView(placeholder())
}
}
}
}
.onAppear {
loadFromCache()
}
}
private func loadFromCache() {
if let cached = ImageCache.shared.object(forKey: url as NSURL) {
cachedImage = cached
}
}
private func saveToCache(from url: URL) {
Task {
do {
let (data, _) = try await URLSession.shared.data(from: url)
if let uiImage = UIImage(data: data) {
await MainActor.run {
ImageCache.shared.setObject(uiImage, forKey: url as NSURL)
if cachedImage == nil {
cachedImage = uiImage
}
}
}
} catch {
print("Image caching failed: \(error)")
}
}
}
}
When the view appears, loadFromCache()
first checks if the image is already stored. If it’s found, the cached image is displayed immediately. If it’s not cached, the code falls back to using AsyncImage
. When AsyncImage
loads successfully, saveToCache(from:)
stores the image for future use.
The design uses separate generics for content and placeholder so that different view types can be supported (for example, ProgressView
vs. styled images). It also applies AnyView
type erasure to make sure all switch cases return the same view type, and uses MainActor.run
since UI updates must happen on the main thread.
Using our cached version is just as simple as the original:
CachedAsyncImage(
url: URL(string: "https://example.com/image.jpg")!,
content: { image in
image
.resizable()
.scaledToFit()
},
placeholder: {
ProgressView()
}
)
.frame(width: 150, height: 150)
With this implementation, the first load downloads the image from the network and caches it. On subsequent loads, the image displays instantly from memory. Memory management is handled automatically by NSCache
under memory pressure, and overall performance improves with dramatically faster loading times for repeated images.
To see the difference in action, you can create a simple comparison view that loads the same image with both approaches. You'll notice that after the first load, the cached version appears instantly while the regular AsyncImage
shows a loading state each time.
And that's it! Image caching transforms the user experience of iOS apps by eliminating redundant network requests and providing instant image loading. The CachedAsyncImage
implementation shown above demonstrates how you can enhance SwiftUI's native AsyncImage
with memory caching using just a few lines of code.
This approach provides immediate performance benefits with minimal complexity, making it perfect for most SwiftUI applications.