Introduction

When loading images asynchronously in SwiftUI, especially within List or LazyVStack, cell reuse can cause images to display the wrong content. The image loaded for one cell appears in another cell because the async task completes after the cell has been reused for different data. Additionally, custom image caching implementations may use incorrect cache keys, causing placeholder images or wrong images to be returned from cache.

Symptoms

  • List shows image from a different row after scrolling
  • Placeholder image persists even after actual image downloads
  • Same image appears in multiple rows with different data
  • Image flashes from one row's image to another during scroll
  • Cached image does not match the URL

Debug image loading: ``swift AsyncImage(url: url) { phase in switch phase { case .empty: ProgressView() case .success(let image): image .onAppear { print("Image loaded for: \(url?.absoluteString ?? "nil")") } case .failure(let error): Image(systemName: "photo") .onAppear { print("Image failed: \(error)") } @unknown default: Image(systemName: "photo") } }

Common Causes

  • Cell reuse in List/LazyVStack displaying stale async image
  • Image cache key does not include all identifying parameters
  • Async task not cancelled when view disappears
  • Placeholder stored in cache as the final image
  • URL session image cache returning stale response

Step-by-Step Fix

  1. 1.**Use AsyncImage with proper ID to prevent cell reuse issues":
  2. 2.```swift
  3. 3.struct ImageRow: View {
  4. 4.let item: Item

var body: some View { HStack { AsyncImage(url: item.imageURL) { image in image.resizable().scaledToFill() } placeholder: { Color.gray } .frame(width: 60, height: 60) .clipped() .id(item.imageURL) // CRITICAL: Forces new view for each URL

Text(item.name) } } }

// The .id() modifier ensures SwiftUI treats each URL as a distinct view, // preventing cell reuse from showing the wrong image ```

  1. 1.**Build a custom async image loader with proper cache keys":
  2. 2.```swift
  3. 3.@MainActor
  4. 4.class ImageLoader: ObservableObject {
  5. 5.@Published var image: UIImage?
  6. 6.@Published var isLoading = false

private var task: Task<Void, Never>? private static let cache = NSCache<NSString, UIImage>()

func load(from url: URL) { // Cancel any existing task task?.cancel()

// Check cache first let cacheKey = url.absoluteString as NSString if let cached = ImageLoader.cache.object(forKey: cacheKey) { image = cached return }

isLoading = true

// Start new task task = Task.detached { [weak self] in guard let self = self else { return }

do { let (data, response) = try await URLSession.shared.data(from: url)

// Check for cancellation before processing guard !Task.isCancelled else { return }

guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode), let uiImage = UIImage(data: data) else { await MainActor.run { self.isLoading = false } return }

// Cache the image ImageLoader.cache.setObject(uiImage, forKey: cacheKey)

await MainActor.run { // Double-check cancellation on main thread guard !Task.isCancelled else { return } self.image = uiImage self.isLoading = false } } catch { await MainActor.run { guard !Task.isCancelled else { return } self.isLoading = false } } } }

func cancel() { task?.cancel() task = nil }

deinit { task?.cancel() } } ```

  1. 1.**Use AsyncImage with custom content and cache policy":
  2. 2.```swift
  3. 3.struct CachedAsyncImage: View {
  4. 4.let url: URL
  5. 5.let placeholder: AnyView

var body: some View { AsyncImage( url: url, // Use .reloadIgnoringLocalCacheData to avoid stale cache // or .returnCacheDataElseLoad for normal behavior transaction: Transaction(animation: .easeInOut) ) { phase in switch phase { case .empty: placeholder case .success(let image): image.resizable().scaledToFill() case .failure: Image(systemName: "exclamationmark.triangle") .foregroundColor(.red) @unknown default: placeholder } } } } ```

Prevention

  • Always use .id() on AsyncImage with the image URL as the key
  • Cancel in-flight image loading tasks when views disappear
  • Use unique cache keys that include URL and transformation parameters
  • Check Task.isCancelled before updating UI with loaded images
  • Use NSCache (memory-only) instead of disk cache for transient images
  • Test image loading by rapidly scrolling through long lists