Implementing image caching with NSCache in iOS addresses a common real-world challenge: efficiently loading images without compromising performance or user experience. It's a lightweight, native solution that avoids third-party dependencies—ideal for developers building lean apps. Additionally, it serves as a natural introduction to memory management concepts, such as how NSCache automatically evicts objects under memory pressure.
This approach helps newer developers avoid common pitfalls like UI flicker or image reloads in scrollable views and sets the stage for more advanced caching strategies, including disk storage or custom loaders.
In this guide, we'll walk you through a simple iOS app implementation to demonstrate the process step by step.
NSCache
NSCache
is a specialized caching class in Apple’s Foundation framework that provides a convenient way to temporarily store key-value pairs in memory. It functions similarly to a dictionary but is optimized for situations where you want the system to manage memory usage more intelligently. One of its biggest advantages is that it automatically evicts stored items when the system is under memory pressure, helping to keep your app responsive and efficient without needing manual intervention.
Unlike regular dictionaries, NSCache
is thread-safe, which means you can access and modify it from different threads without adding synchronization logic. It’s designed to work well with class-type keys, such as NSString
or NSURL
, and object-type values like UIImage
or custom model classes. Additionally, you can set limits on how many objects it holds or how much memory it should use, and even assign a «cost» to each object (like its file size) to help the cache prioritize what to keep or remove.
NSCache
is especially useful in cases like image loading in SwiftUI apps, where images fetched from the network can be reused rather than redownloaded. However, it only stores data temporarily in memory and doesn’t support expiration dates out of the box, so you’d need to add your own logic if you want time-based invalidation. For long-term storage or persistent caching, developers often combine NSCache
with disk storage strategies to create a hybrid caching system.
This is our NSCache wrapping implementation:
import UIKit
actor DiskImageCache {
static let shared = DiskImageCache()
private let memoryCache = NSCache<NSURL, UIImage>()
private let fileManager = FileManager.default
private let cacheDirectory: URL
private let expiration: TimeInterval = 12 * 60 * 60 // 12 hours
init() {
let directory = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first!
cacheDirectory = directory.appendingPathComponent("ImageCache", isDirectory: true)
if !fileManager.fileExists(atPath: cacheDirectory.path) {
try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
}
}
func image(for url: URL) -> UIImage? {
// 1. Check memory cache
if let memoryImage = memoryCache.object(forKey: url as NSURL) {
return memoryImage
}
// 2. Check disk cache
let path = cachePath(for: url)
guard fileManager.fileExists(atPath: path.path) else { return nil }
// Check expiration
if let attributes = try? fileManager.attributesOfItem(atPath: path.path),
let modifiedDate = attributes[.modificationDate] as? Date {
if Date().timeIntervalSince(modifiedDate) > expiration {
try? fileManager.removeItem(at: path)
return nil
}
}
// Load from disk
guard let data = try? Data(contentsOf: path),
let image = UIImage(data: data) else {
return nil
}
memoryCache.setObject(image, forKey: url as NSURL)
return image
}
func store(_ image: UIImage, for url: URL) async {
memoryCache.setObject(image, forKey: url as NSURL)
let path = cachePath(for: url)
if let data = image.pngData() {
try? data.write(to: path)
}
}
private func cachePath(for url: URL) -> URL {
let fileName = url.absoluteString.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? UUID().uuidString
return cacheDirectory.appendingPathComponent(fileName)
}
}
Making DiskImageCache
an actor is all about thread safety — especially when you’re doing I/O (disk reads/writes) and managing a shared resource (the cache itself).
The code defines a DiskImageCache
actor that manages a two-level cache system for images, combining in-memory and disk storage to efficiently store and retrieve images. The actor is implemented as a singleton (shared
), ensuring thread-safe access to its caching mechanisms. It uses NSCache
for fast in-memory storage of UIImage
objects keyed by their URLs, while also maintaining a disk-based cache in the app’s Caches directory under an «ImageCache» subfolder. The disk cache includes an expiration mechanism (12 hours) that automatically removes stale files based on their modification date.
The actor provides two main methods: image(for:)
to retrieve an image and store(_:for:)
to save an image. When retrieving an image, it first checks the memory cache, then falls back to disk if needed, while also handling cache expiration. When storing an image, it saves to both memory and disk. The disk cache uses URL-encoded filenames derived from the image URLs to maintain unique file paths. This implementation balances performance (with quick memory access) and persistence (with disk storage), while managing resource usage through expiration and proper file system organization.
The viewmodel for AsyncImage View component
The ViewModel
is responsible for requesting an image from ImageCache
and providing it to the view via @Published
. Note that the entire class is executed on the @MainActor
, whereas DiskImageCache
runs in a separate actor.
The target (and project) is configured for Swift 6 with Strict Concurrency Checking set to Complete. Since the cache operates in a different isolated domain, even though the image
function is not explicitly marked as async
, the compiler still requires the use of the await
keyword when calling it.
import SwiftUI
@MainActor
class AsyncImageLoader: ObservableObject {
@Published var image: UIImage?
private var url: URL
init(url: URL) {
self.url = url
}
func load() async {
if let cached = await DiskImageCache.shared.image(for: url) {
self.image = cached
return
}
do {
let (data, _) = try await URLSession.shared.data(from: url)
guard let downloaded = UIImage(data: data) else { return }
self.image = downloaded
await DiskImageCache.shared.store(downloaded, for: url)
} catch {
print("Image load failed:", error)
}
}
}
AsyncImageView
struct defines a custom view for asynchronously loading and displaying an image from a URL using an AsyncImageLoader
(assumed to handle the async fetching logic). It uses a placeholder image while the actual image is being fetched, and applies a customizable image styling closure to either the loaded image or the placeholder. The loading is triggered when the view appears, using Swift’s concurrency features (Task
and await
). It leverages @StateObject
to maintain the image loader’s lifecycle across view updates, ensuring image state persists appropriately in the SwiftUI environment.
The AsyncImage view component
Refactored AsyncImageLoader
into a standalone component to enable better reusability.
import SwiftUI
struct AsyncImageView: View {
@StateObject private var loader: AsyncImageLoader
let placeholder: Image
let imageStyle: (Image) -> Image
init(
url: URL,
placeholder: Image = Image(systemName: "photo"),
imageStyle: @escaping (Image) -> Image = { $0 }
) {
_loader = StateObject(wrappedValue: AsyncImageLoader(url: url))
self.placeholder = placeholder
self.imageStyle = imageStyle
}
var body: some View {
Group {
if let uiImage = loader.image {
imageStyle(Image(uiImage: uiImage).resizable())
} else {
imageStyle(placeholder.resizable())
.onAppear {
Task {
await loader.load()
}
}
}
}
}
}
AsyncImageView
struct defines a custom view that asynchronously loads and displays an image from a given URL. It uses an AsyncImageLoader
to manage the image fetching logic. Upon initialization, the view sets up the loader with the provided URL and stores a placeholder image and an optional styling closure for the image. In the view’s body, it conditionally displays the downloaded image if available, or the placeholder image otherwise. When the placeholder is shown, it triggers the asynchronous loading of the image via a Task
inside onAppear
. The imageStyle
closure allows the caller to customize how the image (or placeholder) is displayed, such as adding modifiers like .aspectRatio
or .frame
.
The View
Finally AsyncImageView component is integrated in ContentView in following way:
struct ContentView: View {
var body: some View {
AsyncImageView(url: URL(string: "https://picsum.photos/510")!)
.frame(width: 200, height: 200)
.clipShape(RoundedRectangle(cornerRadius: 20))
.shadow(radius: 5)
}
}
When we deploy iOS app on a real device:

When the app starts up, it displays a placeholder image while the actual image is being fetched. Once the image is displayed, the app is terminated. On the second startup, the app directly shows the previously fetched image.
Conclusions
With that post, I just intended to show how to cache images, or any other resource for making your apps more fluid.
You can find source code used for writing this post in following repository.
References
- NSCache
Apple Developer Documentation
- iOS Memory Deep Dive
WWDC 2018