Question 3
Image loading and rendering performance
Your screen shows lots of remote images and scrolling gets slow. How would you optimize loading, decoding, caching, and rendering?
Follow-ups
- Why does decoding matter?
- Resize before display? Memory vs disk cache?
Answer outline
Slow image feeds usually come from decoding full-resolution bitmaps on the main thread and keeping too many of them in memory. Work through four areas, because each has a distinct cause and fix:
- 1.Decoding:
UIImagedecodes a large bitmap on first draw, which costs CPU and RAM. Downsample to the display pixel size withImageIOinstead of decoding full resolution into a small view. - 2.Loading: fetch asynchronously and cancel on cell reuse. Prefetch the next rows through the prefetching data source API.
- 3.Caching: keep an
NSCacheof decoded bitmaps in memory for fast reuse, andURLCacheor a library on disk for cold start and scroll-back. Cap both, because unbounded caches end in out-of-memory kills. - 4.Rendering: avoid scaling huge images in
draw(_:)and prefer pre-sized assets.cornerRadiusand masks can trigger offscreen passes, so simplify or precompose the effect.
The single biggest win is matching decode size to display size. A 4000 by 3000 photo decoded at full size is about 48 MB at 4 bytes per pixel. The same image downsampled for a 64-point thumbnail at 3x is under 150 KB.
Principles
- Match the decode size to the on-screen dimensions times the display scale, because bytes downloaded don't equal pixels shown.
- Cancel in-flight loads on cell reuse, and check the cell still wants that URL after each
await. - Disk cache is cheaper than RAM pressure, so cap memory caches and watch their growth in large feeds.
- Decode off the main thread and hand back only the finished
UIImageto assign on the main actor.
An actor serializes cache access and keeps the fetch and decode in the background, so only the image assignment touches the main actor:
import ImageIO
import UIKit
/// Owns the session and the in-memory cache. Actor isolation serializes cache access.
actor ThumbnailPipeline {
private let memoryCache = NSCache<NSURL, UIImage>()
private let session: URLSession
init(session: URLSession = .shared) {
self.session = session
}
/// Fetch, decode at thumbnail size, and cache before returning.
func loadImage(
url: URL,
pointSize: CGSize,
scale: CGFloat
) async throws -> UIImage {
let key = url as NSURL
if let cached = memoryCache.object(forKey: key) { return cached }
let (data, _) = try await session.data(from: url)
let decoded = try await decodeDownsampled(
data: data,
maxPixelSize: max(pointSize.width, pointSize.height) * scale
)
memoryCache.setObject(decoded, forKey: key)
return decoded
}
/// Runs the CPU-heavy decode in a detached task so it blocks neither the actor nor the main thread.
private func decodeDownsampled(data: Data, maxPixelSize: CGFloat) async throws -> UIImage {
try await Task.detached(priority: .userInitiated) {
let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
guard let source = CGImageSourceCreateWithData(data as CFData, sourceOptions) else {
throw URLError(.cannotDecodeContentData)
}
let downsample: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceThumbnailMaxPixelSize: Int(maxPixelSize),
kCGImageSourceCreateThumbnailWithTransform: true,
]
guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, downsample as CFDictionary) else {
throw URLError(.cannotDecodeContentData)
}
return UIImage(cgImage: cgImage)
}.value
}
}
// Usage from a cell. Re-check the bound URL after the await so a reused cell never shows a stale image.
// let thumbnails = ThumbnailPipeline()
// Task {
// let image = try await thumbnails.loadImage(url: url, pointSize: thumb.bounds.size, scale: traitCollection.displayScale)
// guard url == self.boundURL else { return }
// await MainActor.run { self.thumb.image = image }
// }
AsyncImage loads an image from a URL with no boilerplate. You don't control the decode size and it caches only through URLCache, so it suits icons rather than heavy feeds:
import SwiftUI
AsyncImage(url: url, scale: UIScreen.main.scale) { phase in
switch phase {
case .empty:
ProgressView()
case .success(let image):
image
.resizable()
.scaledToFill()
case .failure:
Image(systemName: "photo")
@unknown default:
EmptyView()
}
}
.frame(width: 64, height: 64)
.clipped()
Inject one ThumbnailPipeline and load with .task(id: url), so SwiftUI cancels the previous load when the URL changes. That's the same identity idea as UIKit cell reuse:
import SwiftUI
struct FeedThumb: View {
let url: URL
let pipeline: ThumbnailPipeline
@Environment(\.displayScale) private var displayScale
@State private var image: UIImage?
var body: some View {
Group {
if let image {
Image(uiImage: image)
.resizable()
.scaledToFill()
} else {
Color.secondary.opacity(0.2)
}
}
.frame(width: 64, height: 64)
.clipped()
.task(id: url) {
image = nil // Reset for the new URL, the same way prepareForReuse would
let loaded = try? await pipeline.loadImage(
url: url,
pointSize: CGSize(width: 64, height: 64),
scale: displayScale
)
guard !Task.isCancelled else { return }
image = loaded
}
}
}
Follow-up angles
- HEIF and JPEG decoding isn't free. The worst case is width times height times 4 bytes per pixel held in memory for every decoded image.



