← All topics/Performance & optimization

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. 1.Decoding: UIImage decodes a large bitmap on first draw, which costs CPU and RAM. Downsample to the display pixel size with ImageIO instead of decoding full resolution into a small view.
  2. 2.Loading: fetch asynchronously and cancel on cell reuse. Prefetch the next rows through the prefetching data source API.
  3. 3.Caching: keep an NSCache of decoded bitmaps in memory for fast reuse, and URLCache or a library on disk for cold start and scroll-back. Cap both, because unbounded caches end in out-of-memory kills.
  4. 4.Rendering: avoid scaling huge images in draw(_:) and prefer pre-sized assets. cornerRadius and 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 UIImage to 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:

Fetch, downsample, cache, then assign on 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:

SwiftUI: AsyncImage
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:

SwiftUI: the same pipeline behind .task(id:)
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.