CtrlK
BlogDocsLog inGet started
Tessl Logo

ivan-magda/uikit-expert

Write, review, or improve UIKit code following best practices for view controller lifecycle, Auto Layout, collection views, navigation, animation, memory management, and modern iOS 18–26 APIs. Use when building new UIKit features, refactoring existing views or view controllers, reviewing code quality, adopting modern UIKit patterns (diffable data sources, compositional layout, cell configuration), or bridging UIKit with SwiftUI. Does not cover SwiftUI-only code.

96

1.23x
Quality

100%

Does it follow best practices?

Impact

96%

1.23x

Average score across 9 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files

image-loading.mdreferences/

UIKit image loading, caching, and downsampling in Swift

Every decoded image consumes width × height × 4 bytes of RAM — a single 12MP iPhone photo eats ~48 MB regardless of its 2 MB JPEG file size. This mismatch between compressed file size and decoded bitmap size is the root cause of most image-related memory crashes in iOS apps. This guide covers the complete modern toolkit for efficient image handling: ImageIO downsampling, iOS 15+ preparation APIs, NSCache strategies, cell reuse race conditions, SF Symbols, the actor-based ImageLoader pattern, and when third-party libraries still earn their keep. All code targets Swift 5.9+ / iOS 15+ with notes on WWDC 2024–2025 updates.


Why a 2 MB JPEG explodes into 48 MB of RAM

iOS renders images through a three-stage pipeline: Load (compressed bytes into a data buffer), Decode (CPU decompresses into a per-pixel bitmap), and Render (bitmap copied into the frame buffer at 60–120 Hz). The decode stage is where memory balloons. The decoded bitmap buffer is proportional to pixel dimensions, not file size.

The formula is straightforward:

Decoded memory = width × height × bytesPerPixel

For standard sRGB images, each pixel requires 4 bytes (one byte each for red, green, blue, and alpha). A 12 MP iPhone photo at 4032 × 3024 pixels therefore consumes 4032 × 3024 × 4 = 48,771,072 bytes ≈ 48 MB. Wide color (Display P3) images on iPhone 7+ double that to 8 bytes per pixel, pushing the same photo to ~97 MB.

Image sourcePixel dimensionsJPEG on diskDecoded in RAM
iPhone 12MP photo4032 × 3024~2–4 MB~48 MB
iPad screenshot2048 × 1536~590 KB~12.6 MB
NASA high-res12,000 × 12,000~20 MB~576 MB
Thumbnail in a cell300 × 300 @3x (900px)~50 KB~3.2 MB

Loading 20 full-resolution photos into a scrolling grid means ~960 MB of decoded bitmaps — well past jetsam limits on most devices. The OS responds by compressing memory (burning CPU), then killing background apps, and ultimately terminating your app with EXC_RESOURCE_EXCEPTION. As Apple's Kyle Sluder explained at WWDC 2018 (Session 219, "Image and Graphics Best Practices"): "The size of this buffer in memory is proportional to the size of the input image — not the size of the image view."

The fix is to never decode at full resolution when displaying at a smaller size. That's where downsampling enters.


ImageIO downsampling: decode only what you display

The CGImageSourceCreateThumbnailAtIndex API from the ImageIO framework is the gold-standard technique for memory-efficient image loading. Unlike UIImage-based resizing — which decompresses the full bitmap first, then scales — ImageIO uses streaming I/O to read just enough data to produce a thumbnail at the target size, never touching the full-resolution bitmap.

❌ Incorrect: loading full-resolution then scaling

// ❌ BAD — Decodes the ENTIRE image into memory first, then scales
let fullImage = UIImage(contentsOfFile: url.path)!          // 48 MB decoded
let renderer = UIGraphicsImageRenderer(size: thumbnailSize)
let thumbnail = renderer.image { _ in
    fullImage.draw(in: CGRect(origin: .zero, size: thumbnailSize))
}
// Peak memory: ~48 MB even though you only need a 300×300 thumbnail

✅ Correct: ImageIO downsampling

import ImageIO
import UIKit

func downsample(imageAt url: URL,
                to pointSize: CGSize,
                scale: CGFloat = UIScreen.main.scale) -> UIImage? {

    // 1. Create image source — kCGImageSourceShouldCache: false prevents
    //    decoding the full image just to read metadata
    let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
    guard let source = CGImageSourceCreateWithURL(url as CFURL, sourceOptions) else {
        return nil
    }

    // 2. Calculate target pixel size
    let maxDimension = max(pointSize.width, pointSize.height) * scale

    // 3. Create thumbnail at target size — only the downsampled image is decoded
    let downsampleOptions: [CFString: Any] = [
        kCGImageSourceCreateThumbnailFromImageAlways: true,   // Always generate from image data
        kCGImageSourceShouldCacheImmediately: true,           // Decode NOW on this thread, not lazily on main
        kCGImageSourceCreateThumbnailWithTransform: true,     // Apply EXIF orientation
        kCGImageSourceThumbnailMaxPixelSize: maxDimension     // Max width or height in pixels
    ]
    guard let cgImage = CGImageSourceCreateThumbnailAtIndex(
        source, 0, downsampleOptions as CFDictionary
    ) else {
        return nil
    }

    return UIImage(cgImage: cgImage)
}

Each option serves a specific purpose. kCGImageSourceShouldCache: false on the source tells Core Graphics not to cache the full-resolution decoded image in memory — you only want metadata. kCGImageSourceShouldCacheImmediately: true on the thumbnail forces decoding on the current (background) thread rather than deferring it to the main thread during rendering. kCGImageSourceThumbnailMaxPixelSize caps the longest edge; the image scales proportionally to fit. Omitting this key produces a full-size "thumbnail" — defeating the purpose entirely.

When working with in-memory Data instead of a file URL, swap CGImageSourceCreateWithURL for CGImageSourceCreateWithData:

guard let source = CGImageSourceCreateWithData(data as CFData, sourceOptions) else {
    return nil
}

The memory savings are dramatic. WWDC 2018 showed a grid demo dropping from 31.5 MiB to 18.4 MiB with downsampling. Real-world benchmarks show even larger gains: a NASA 12,000 × 12,000 image dropped from 220 MB to 24 MB when displayed at screen size.


iOS 15+ preparation APIs simplify the common cases

iOS 15 introduced six UIImage methods that wrap ImageIO's complexity behind a cleaner interface. They fall into two categories: full-size decode (move decompression off the main thread) and thumbnail (downsample + decode in one call).

Full-size decoding APIs

Use these when the image is already the right size but you want to pre-decode it off the main thread to avoid a hitch during rendering:

// Synchronous — call on a background queue
let decoded = image.preparingForDisplay()

// Completion handler — system manages the background queue
image.prepareForDisplay { decoded in
    DispatchQueue.main.async { imageView.image = decoded }
}

// async/await — cleanest syntax
let decoded = await image.byPreparingForDisplay()

Thumbnail APIs (downsample + decode)

Use these when displaying a large image in a small view — they combine downsampling and decoding:

// Synchronous — call on a background queue
let thumb = image.preparingThumbnail(of: CGSize(width: 200, height: 200))

// Completion handler
image.prepareThumbnail(of: CGSize(width: 200, height: 200)) { thumb in
    DispatchQueue.main.async { cell.imageView.image = thumb }
}

// async/await
let thumb = await image.byPreparingThumbnail(ofSize: CGSize(width: 200, height: 200))

The size parameter is in points. The system multiplies by the screen scale internally. All methods return nil if the image is backed by CIImage rather than CGImage.

When to pick which

ScenarioBest API
Local image, already correct size, need background decodebyPreparingForDisplay()
Local large image, displaying in small viewbyPreparingThumbnail(ofSize:)
Network data, maximum memory control, pre-iOS 15 supportImageIO CGImageSourceCreateThumbnailAtIndex
Already have a UIImage, quick cell configurationprepareThumbnail(of:completionHandler:)

ImageIO remains the most memory-efficient option because it works directly from compressed data without ever instantiating a full UIImage. The iOS 15 APIs require you to create a UIImage first, which may briefly touch the full bitmap. For network images where you receive raw Data, ImageIO is the better choice.


NSCache: cost by decoded bytes, not file size

NSCache is a thread-safe, auto-evicting, in-memory key-value store — purpose-built for expensive-to-recreate objects like decoded images. The critical mistake most developers make is setting totalCostLimit based on compressed file size rather than decoded bitmap size.

❌ Incorrect: cost based on file size

// ❌ BAD — PNG/JPEG data size vastly underestimates actual memory
let cost = image.pngData()?.count ?? 0   // ~2 MB for a 48 MB decoded image
cache.setObject(image, forKey: key, cost: cost)
// NSCache thinks it's using 2 MB — it's actually using 48 MB

✅ Correct: cost based on decoded bitmap

final class ImageCache {
    static let shared = ImageCache()
    private let cache = NSCache<NSString, UIImage>()

    private init() {
        cache.countLimit = 100
        // 20% of physical memory, capped at Int.max
        let physical = ProcessInfo.processInfo.physicalMemory
        cache.totalCostLimit = Int(min(physical / 5, UInt64(Int.max)))
        cache.name = "com.app.decodedImageCache"

        NotificationCenter.default.addObserver(
            forName: UIApplication.didReceiveMemoryWarningNotification,
            object: nil, queue: .main
        ) { [weak self] _ in
            self?.cache.removeAllObjects()
        }
    }

    func image(forKey key: String) -> UIImage? {
        cache.object(forKey: key as NSString)
    }

    func setImage(_ image: UIImage, forKey key: String) {
        let cost = Self.decodedByteCount(for: image)
        cache.setObject(image, forKey: key as NSString, cost: cost)
    }

    func removeImage(forKey key: String) {
        cache.removeObject(forKey: key as NSString)
    }

    /// Actual decoded bitmap size: bytesPerRow × height
    /// More accurate than width × height × 4 because bytesPerRow
    /// accounts for row alignment padding the system may add.
    static func decodedByteCount(for image: UIImage) -> Int {
        guard let cgImage = image.cgImage else { return 0 }
        return cgImage.bytesPerRow * cgImage.height
    }
}

Three details worth noting. First, NSCache uses NSString keys — using Swift String incurs bridging overhead on every lookup. Second, without totalCostLimit or countLimit set, NSCache will not proactively evict objects (a change since iOS 7). Third, the memory warning observer is belt-and-suspenders: NSCache auto-purges under memory pressure, but an explicit removeAllObjects() call ensures immediate release.

Setting totalCostLimit to 10–20% of physical memory (the approach used by Nuke) strikes a balance between cache hit rate and leaving headroom for the rest of the app. On a 6 GB iPhone 15 Pro, that's 600 MB–1.2 GB of cached decoded images.


Cell reuse race conditions: the three-step fix

When a UICollectionView or UITableView cell scrolls off-screen, UIKit reuses it for new content. If an image download is in-flight for the old content, the classic race condition occurs: the old download completes after the cell is reused, and the wrong image appears — often with a visible flicker as it's overwritten by the correct image moments later.

❌ Incorrect: no cancellation, no clearing, no identity check

// ❌ BAD — Every line is a bug waiting to happen
class BadCell: UICollectionViewCell {
    @IBOutlet var cellImageView: UIImageView!

    func configure(with url: URL) {
        // ❌ Stale image from previous item remains visible
        // ❌ Previous download still running — wastes bandwidth
        // ❌ No identity check — wrong image will overwrite correct one

        Task { [weak self] in
            let (data, _) = try await URLSession.shared.data(from: url)
            let image = UIImage(data: data)
            self?.cellImageView.image = image  // ❌ Cell may represent a different item now
        }
    }

    // ❌ No prepareForReuse override at all
}

✅ Correct: cancel, clear, verify

// ✅ GOOD — Three-step defense against race conditions
class ImageCell: UICollectionViewCell {
    @IBOutlet var cellImageView: UIImageView!

    private var loadTask: Task<Void, Never>?
    private var currentURL: URL?

    func configure(with url: URL, cache: ImageCache = .shared) {
        currentURL = url

        // Step 0: Check cache first (synchronous, no race possible)
        if let cached = cache.image(forKey: url.absoluteString) {
            cellImageView.image = cached
            return
        }

        // Step 1: Clear stale image immediately
        cellImageView.image = nil

        // Step 2: Cancel any in-flight task from previous configuration
        loadTask?.cancel()

        // Step 3: Start new download with identity verification
        loadTask = Task { [weak self] in
            do {
                let (data, _) = try await URLSession.shared.data(from: url)
                try Task.checkCancellation()

                guard let image = UIImage(data: data) else { return }
                cache.setImage(image, forKey: url.absoluteString)

                // ✅ CRITICAL: Verify this cell still represents the same URL
                guard self?.currentURL == url else { return }
                self?.cellImageView.image = image
            } catch is CancellationError {
                // Cell was reused — task correctly cancelled, do nothing
            } catch {
                // Network error handling (placeholder, retry, etc.)
            }
        }
    }

    override func prepareForReuse() {
        super.prepareForReuse()
        loadTask?.cancel()        // ✅ Cancel in-flight download
        loadTask = nil
        cellImageView.image = nil // ✅ Clear stale image
        currentURL = nil          // ✅ Reset identity tracker
    }
}

The three required steps in prepareForReuse() are: cancel the in-flight task, clear the image view, and reset the identity tracker. On completion, the guard self?.currentURL == url check is the final defense — even if cancellation didn't propagate in time, the stale result is silently discarded. The Task.checkCancellation() call after the network await provides an early exit if the task was cancelled during the download.


SF Symbols: vector icons with semantic meaning

SF Symbols provides 6,000+ vector icons that scale with Dynamic Type, support multiple rendering modes, and adapt to weight and accessibility settings automatically. In UIKit, load them with UIImage(systemName:) and configure them with UIImage.SymbolConfiguration.

// Basic loading
let star = UIImage(systemName: "star.fill")

// Configured with point size, weight, and scale
let config = UIImage.SymbolConfiguration(pointSize: 24, weight: .bold, scale: .large)
let configuredStar = UIImage(systemName: "star.fill", withConfiguration: config)

// Combine multiple configurations with .applying()
let sizeConfig = UIImage.SymbolConfiguration(textStyle: .headline)
let weightConfig = UIImage.SymbolConfiguration(weight: .heavy)
let combined = sizeConfig.applying(weightConfig)

SF Symbols supports four rendering modes. Monochrome applies a single tint color. Hierarchical applies one color with varying opacity per layer for depth. Palette assigns distinct colors to primary, secondary, and tertiary layers. Multicolor uses the symbol's intrinsic colors (a green leaf, a red trash icon).

// Hierarchical — one color, layered opacity
let hierarchical = UIImage.SymbolConfiguration(hierarchicalColor: .systemBlue)
imageView.image = UIImage(systemName: "person.3.fill", withConfiguration: hierarchical)

// Palette — explicit colors per layer
let palette = UIImage.SymbolConfiguration(paletteColors: [.white, .systemBlue, .systemTeal])
imageView.image = UIImage(systemName: "person.3.fill", withConfiguration: palette)

// Multicolor — intrinsic colors
let multicolor = UIImage.SymbolConfiguration.configurationPreferringMulticolor()
imageView.image = UIImage(systemName: "cloud.sun.rain.fill", withConfiguration: multicolor)

Set preferredSymbolConfiguration on a UIImageView to apply configuration at the view level rather than baking it into the image. iOS 16+ introduced variable color (UIImage(systemName: "speaker.wave.3", variableValue: 0.5)) for progress indicators. SF Symbols 6 (2024) added wiggle, breathe, and rotate animation presets. SF Symbols 7 (2025) added draw-on/draw-off animations and gradient fills.


When third-party libraries still earn their place

Native iOS (URLSession + NSCache + ImageIO + Swift actors) can handle straightforward image loading without dependencies. Third-party libraries become justified when your requirements exceed what native provides in a reasonable engineering budget.

Native covers: network downloading, in-memory caching (NSCache), HTTP-level disk caching (URLCache), downsampling (ImageIO), off-main-thread decoding (iOS 15+ APIs), and thread safety (actors). It does not provide: configurable disk caching with expiration policies, image processing pipelines (blur, round corners, tint cached per-variant), progressive JPEG rendering, animated GIF/WebP/APNG playback, prefetch coordination, retry/authentication middleware, or transition animations.

The three leading libraries each occupy a distinct niche. Kingfisher (~23K GitHub stars) is the most popular pure-Swift option, offering the simplest API (imageView.kf.setImage(with: url)), built-in image processors that cache processed results, and excellent SwiftUI support via KFImage. SDWebImage (~25K stars) is the most mature option with Objective-C roots, the broadest animated format support, and an extensible coder plugin system for WebP, AVIF, and HEIF. Nuke (~8.5K stars) is the most performance-focused, featuring a composable pipeline architecture, resumable downloads, dynamic request prioritization, rate limiting, and the smallest binary footprint with sub-2-second compile times.

Choose native when your app displays fewer than ~50 images, your backend serves properly sized images, and you don't need disk caching or image transformations. Choose a library for image-heavy apps (social feeds, e-commerce catalogs, photo galleries) where the engineering cost of replicating disk caching, format support, and edge-case handling exceeds the cost of a dependency.


The complete ImageLoader actor

This implementation combines every technique discussed: NSCache with bitmap-cost accounting, ImageIO downsampling, in-flight request deduplication, and Swift actor isolation for thread safety. No locks, no dispatch queues — the actor serializes all access to shared state automatically.

import UIKit
import ImageIO

actor ImageLoader {
    static let shared = ImageLoader()

    private let cache = NSCache<NSString, UIImage>()
    private var inFlight: [URL: Task<UIImage, Error>] = [:]

    init() {
        cache.countLimit = 150
        let memoryBudget = ProcessInfo.processInfo.physicalMemory / 5  // 20%
        cache.totalCostLimit = Int(min(memoryBudget, UInt64(Int.max)))
    }

    // MARK: - Public API

    /// Load an image from cache, or download and optionally downsample it.
    /// - Parameters:
    ///   - url: Remote image URL
    ///   - targetSize: Point size for downsampling. Pass nil for full-size decode.
    func load(from url: URL, targetSize: CGSize? = nil) async throws -> UIImage {
        let key = url.absoluteString as NSString

        // 1. Memory cache hit — instant return
        if let cached = cache.object(forKey: key) {
            return cached
        }

        // 2. De-duplicate: reuse an in-flight task for the same URL
        if let existing = inFlight[url] {
            return try await existing.value
        }

        // 3. Create download + decode task
        let task = Task<UIImage, Error> {
            let (data, _) = try await URLSession.shared.data(from: url)
            try Task.checkCancellation()

            let image: UIImage
            if let size = targetSize {
                image = Self.downsample(data: data, to: size)
                    ?? UIImage(data: data)!
            } else if let full = UIImage(data: data) {
                // Decode on this background thread to avoid main-thread hitch
                image = await full.byPreparingForDisplay() ?? full
            } else {
                throw URLError(.cannotDecodeContentData)
            }
            return image
        }

        // Store BEFORE await — other callers see .inFlight and reuse this task
        inFlight[url] = task

        do {
            let image = try await task.value
            let cost = image.cgImage.map { $0.bytesPerRow * $0.height } ?? 0
            cache.setObject(image, forKey: key, cost: cost)
            inFlight[url] = nil
            return image
        } catch {
            inFlight[url] = nil
            throw error
        }
    }

    /// Cancel a pending load for a specific URL.
    func cancel(for url: URL) {
        inFlight[url]?.cancel()
        inFlight[url] = nil
    }

    /// Evict all cached images (call on memory warning).
    func clearCache() {
        cache.removeAllObjects()
    }

    // MARK: - ImageIO Downsampling (nonisolated — pure function, no shared state)

    nonisolated static func downsample(
        data: Data,
        to pointSize: CGSize,
        scale: CGFloat = UIScreen.main.scale
    ) -> UIImage? {
        let sourceOpts = [kCGImageSourceShouldCache: false] as CFDictionary
        guard let source = CGImageSourceCreateWithData(data as CFData, sourceOpts) else {
            return nil
        }
        let maxDimension = max(pointSize.width, pointSize.height) * scale
        let thumbOpts: [CFString: Any] = [
            kCGImageSourceCreateThumbnailFromImageAlways: true,
            kCGImageSourceShouldCacheImmediately: true,
            kCGImageSourceCreateThumbnailWithTransform: true,
            kCGImageSourceThumbnailMaxPixelSize: maxDimension
        ]
        guard let cg = CGImageSourceCreateThumbnailAtIndex(source, 0, thumbOpts as CFDictionary) else {
            return nil
        }
        return UIImage(cgImage: cg)
    }
}

Integration with collection view cells

class AsyncImageCell: UICollectionViewCell {
    let imageView = UIImageView()
    private var loadTask: Task<Void, Never>?
    private var currentURL: URL?

    func configure(with url: URL, targetSize: CGSize) {
        currentURL = url
        imageView.image = nil
        loadTask?.cancel()

        loadTask = Task { [weak self] in
            do {
                let image = try await ImageLoader.shared.load(
                    from: url, targetSize: targetSize
                )
                guard !Task.isCancelled, self?.currentURL == url else { return }
                self?.imageView.image = image
            } catch {
                guard !Task.isCancelled else { return }
                self?.imageView.image = UIImage(systemName: "photo")
            }
        }
    }

    override func prepareForReuse() {
        super.prepareForReuse()
        loadTask?.cancel()
        loadTask = nil
        imageView.image = nil
        currentURL = nil
    }
}

The deduplication trick is the most subtle part of the actor design. When load(from:) stores the Task in inFlight[url] before awaiting task.value, any concurrent caller for the same URL hits the if let existing = inFlight[url] branch and awaits the same task. This eliminates duplicate network requests when the same image URL appears in multiple visible cells — a common scenario with user avatars.

The downsample method is marked nonisolated because it's a pure function with no shared state — it can run on any thread without actor hop overhead.


What changed at WWDC 2024 and 2025

WWDC 2024 introduced Adaptive HDR as the headline image feature. A new gain-map-based format stores both HDR and SDR representations in a single file, with new APIs like kCGImageSourceDecodeToHDR, UIImageView.preferredImageDynamicRange, and CGImageGetContentHeadroom(). SF Symbols 6 added 800+ new symbols and wiggle/breathe/rotate animation presets. Swift 6 shipped with data-race safety by default — image loading code sharing mutable state across threads must now use actors or the new Mutex type from the Synchronization module.

WWDC 2025 brought Swift 6.2's "Approachable Concurrency" model with default @MainActor isolation and the @concurrent attribute for explicit background work — a cleaner fit for image loading patterns. SF Symbols 7 added draw-on/draw-off animations and gradient fills. UIBackgroundExtensionView enables images to extend seamlessly behind navigation bars in the new Liquid Glass design language. Crucially, no new image caching or loading APIs were introduced — NSCache, ImageIO downsampling, and the iOS 15 prepareThumbnail/prepareForDisplay family remain Apple's recommended toolkit.

Conclusion

The core principle hasn't changed since WWDC 2018: never decode more pixels than you display. What has evolved is the ergonomics. ImageIO downsampling remains the lowest-memory option for network images arriving as raw Data. The iOS 15+ thumbnail APIs provide a cleaner interface when you already have a UIImage. Swift actors eliminate the thread-safety boilerplate that previously required dispatch queues or locks. And NSCache with bitmap-cost accounting gives the memory subsystem accurate information for intelligent eviction.

The ImageLoader actor pattern presented here — combining NSCache, ImageIO downsampling, in-flight deduplication, and structured concurrency — covers 80% of production image loading needs without a single dependency. The remaining 20% (disk caching with TTL, animated formats, progressive rendering, image processing pipelines) is where Kingfisher, SDWebImage, or Nuke justify their inclusion. The decision isn't native versus third-party — it's knowing exactly which capabilities you're buying and whether the engineering cost of building them yourself exceeds adding a well-maintained dependency.

Summary Checklist

  • Images downsampled to display size — never loaded at full resolution (12MP = ~48MB decoded)
  • Downsampling uses ImageIO (CGImageSourceCreateThumbnailAtIndex) for maximum memory efficiency
  • iOS 15+: byPreparingThumbnail(of:) or prepareForDisplay() used for async decoding when appropriate
  • Cell image loading follows cancel/clear/verify pattern in prepareForReuse
  • In-flight Task cancelled in prepareForReuse; imageView.image cleared immediately
  • Completion verifies cell identity (URL/ID match) before applying image
  • NSCache uses totalCostLimit based on decoded bitmap bytes (width × height × 4), not file size
  • Memory warning observer calls cache.removeAllObjects() on didReceiveMemoryWarningNotification
  • SF Symbols use UIImage(systemName:) with SymbolConfiguration for weight/scale/color

SKILL.md

tile.json