Recently, I faced the same issue and discovered that the native AsyncImage in SwiftUI does not cache images as expected. It fetches the image from the network every time the view appears on screen. After some debugging, It seems like the caching behavior of AsyncImage might be broken or unreliable. I’m sharing my findings here in case it helps someone else.
According to Apple’s documentation, AsyncImage uses URLSession under the hood to fetch images. By default, URLSession uses the .useProtocolCachePolicy, which should cache the response as long as the response headers don’t explicitly disable it (e.g., with Cache-Control: no-store).
Here’s a simple SwiftUI view that uses AsyncImage to load an image:
import SwiftUI
struct ContentView: View {
var body: some View {
Rectangle()
.frame(width: 400, height: 400)
.foregroundStyle(.gray)
AsyncImageDemo()
}
}
struct AsyncImageDemo: View {
let url = URL(string: "https://fastly.picsum.photos/id/1/5000/3333.jpg?hmac=Asv2DU3rA_5D1xSe22xZK47WEAN0wjWeFOhzd13ujW4")
var body: some View {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image
.resizable()
.frame(width: 300, height: 200)
.aspectRatio(contentMode: .fit)
@unknown default:
Rectangle()
.frame(width: 200, height: 200)
.foregroundStyle(.blue)
}
}
}
}
When running this code and inspecting network traffic using Charles Proxy or a similar tool, you’ll notice that AsyncImage fetches the image from the network every time the view appears.
Also, if you inspect the app’s container in the simulator, the fsCachedData folder remains empty. I’ve attached a gif demonstrating this. Sorry for adding links here as uploading images or gif fails for me everytime :(
fsCachedData folder: https://i.sstatic.net/AJx2lNy8.png
GIF: https://imgur.com/a/infyUZZ
Interestingly, if you fetch the same image using URLSession.shared manually (e.g., in the init of your view), you’ll see that the image does get cached under fsCachedData.
struct ContentView: View {
init() {
Task {
let urlString = "https://fastly.picsum.photos/id/1/5000/3333.jpg?hmac=Asv2DU3rA_5D1xSe22xZK47WEAN0wjWeFOhzd13ujW4"
let (data, response) = try await URLSession.shared.data(for: URLRequest(url: URL(string: urlString)!))
}
}
var body: some View {
Rectangle()
.frame(width: 400, height: 400)
.foregroundStyle(.gray)
AsyncImageDemo()
}
}
Once this code runs, you’ll find that the cache folder contains the image file in raw format.
fsCachedData folder: https://i.sstatic.net/f5XrOrV6.png
But here’s the interesting part: Now, when you remove the URLSession code and run the view with just AsyncImage, it stops making network calls and instead reuses the cached image.GIF AsyncImage resuing cache: https://imgur.com/a/oGBMrwv
If you delete the cached file and rerun, the network call comes back confirming that AsyncImage does rely on URLSession‘s cache if it already exists, but doesn’t seem to create or trigger it reliably on its own.
So, based on the above findings, AsyncImage appears to reuse the cache if it’s already present. But it doesn’t reliably populate the cache itself. So to tackle this issue we can go with custom cached AsyncImage as mentioned above by others or we can come up with a workaround from above findings.
Workaround
One possible workaround is to manually fetch the image once using URLSession, so that AsyncImage can reuse the cached response later. I agree this isn’t a clean solution, and it does cause two network calls on the first load (one from URLSession, one from AsyncImage). But it ensures that AsyncImage uses the cache afterward.
Here’s a small custom modifier to make that easier:
struct AsyncImageCacheModifier: ViewModifier {
@State private var isAlreadyFetched = false
let url: URL?
init(url: URL?) {
self.url = url
}
func body(content: Content) -> some View {
content
.task {
guard let url, !isAlreadyFetched else { return }
let _ = try? await URLSession.shared.data(for: URLRequest(url: url))
isAlreadyFetched = true
}
}
}
extension AsyncImage {
func enableCacheForURL(url: URL?) -> some View {
modifier(AsyncImageCacheModifier(url: url))
}
}
Usage:
struct AsyncImageDemo: View {
let url = URL(string: "https://fastly.picsum.photos/id/1/5000/3333.jpg?hmac=Asv2DU3rA_5D1xSe22xZK47WEAN0wjWeFOhzd13ujW4")
var body: some View {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image
.resizable()
.frame(width: 300, height: 200)
.aspectRatio(contentMode: .fit)
@unknown default:
Rectangle()
.frame(width: 200, height: 200)
.foregroundStyle(.blue)
}
}
.enableCacheForURL(url: url)
}
}
Again, this is just a workaround, but it might be useful if you want to stick with native AsyncImage and not getting into custom implementation. Hope this helps!
AsyncImagefor iOS 14 which supports caching.