Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 097 / 100

Async image caching: Swift & iOS lessons from production

Fast, smooth remote images.

Expona Admin8 min read

Introduction

Images are everywhere in modern iOS apps—from product galleries in e-commerce platforms to profile pictures in social networks. But fetching and rendering remote images correctly is harder than it looks. We've learned this the hard way in production environments, where slow image loading, memory bloat, and network waste became bottlenecks affecting both performance and user experience. This article covers async image caching: Swift & iOS lessons from production, showing you how to move beyond SwiftUI's AsyncImage and build a robust image loading pipeline that scales.

Why AsyncImage Falls Short in Production

SwiftUI's AsyncImage view is convenient for prototyping, but it comes with serious limitations when you're shipping real products. By default, AsyncImage does not persist images to disk or manage an aggressive in-memory cache. Each time a user navigates away and back to a screen with the same image, AsyncImage fetches it again over the network.

Consider a feed of 50 products, each with a thumbnail. Scrolling quickly back and forth means 50 fresh HTTP requests per scroll cycle. Your users burn through data, the app feels sluggish, and your backend sees unnecessary load.

swift
// Basic AsyncImage - no caching beyond URLSession default
AsyncImage(url: URL(string: "https://example.com/image.jpg")) { phase in
    switch phase {
    case .empty:
        ProgressView()
    case .success(let image):
        image
            .resizable()
            .scaledToFit()
    case .failure:
        Image(systemName: "photo")
    @unknown default:
        EmptyView()
    }
}

The fundamental issue: AsyncImage relies on URLSession's default cache, which is typically limited to 512 MB in-memory. On a device with lots of apps running, that cache gets evicted quickly, forcing re-fetches of the same images.

Configuring URLCache for Real-World Scale

The first step toward production-ready image caching is configuring URLCache with appropriate capacity limits. We control both memory and disk storage, allowing us to balance speed and space.

In your app delegate or as an early initialization step, set up a custom URLCache instance:

swift
import Foundation

func configureURLCache() {
    let memoryCapacity = 500 * 1024 * 1024  // 500 MB in-memory
    let diskCapacity = 5 * 1024 * 1024 * 1024  // 5 GB on disk
    let cache = URLCache(
        memoryCapacity: memoryCapacity,
        diskCapacity: diskCapacity,
        diskPath: "ImageCache"
    )
    URLCache.shared = cache
}

Call this early in your app's lifecycle—typically in the AppDelegate or in a SwiftUI app initializer. This gives every URLSession (including those used by AsyncImage) access to a much larger cache pool.

The trade-off is storage: a 5 GB disk cache will consume space on the user's device. For e-commerce apps with product catalogs, this is worth it. For lightweight apps, dial it down to 1-2 GB. Monitor your app's storage usage in TestFlight and adjust based on real data.

One caveat: URLCache respects HTTP cache headers sent by your backend. If your images have a short TTL or no cache directive, even a large URLCache won't help. Work with your backend team to ensure images are marked with appropriate expiration headers or use cache-control directives.

swift
// Example: backend should return headers like:
// Cache-Control: public, max-age=2592000
// This tells URLCache to keep the image for 30 days

Moving Beyond Defaults with Nuke

URLCache gets you 80% of the way there, but production apps often need more control. Libraries like Nuke, Kingfisher, and SDWebImage offer advanced features: progressive loading, image transformation, memory footprint monitoring, and priority queuing.

We've used Nuke extensively in ride-hailing and travel apps where images load quickly over spotty networks. It handles edge cases that URLCache doesn't: image decompression scheduling, cancellation of in-flight requests, and per-image priority.

Here's how Nuke simplifies async image handling in SwiftUI:

swift
import Nuke
import NukeUI

struct ProductCell: View {
    let imageURL: URL
    
    var body: some View {
        LazyImage(url: imageURL) { state in
            if let image = state.image {
                image
                    .resizable()
                    .scaledToFill()
            } else if state.isLoading {
                ProgressView()
            } else {
                Image(systemName: "photo")
            }
        }
        .frame(height: 200)
    }
}

Nuke handles the complex parts: it decompresses images off the main thread, aggressively manages memory, and can be tuned via a configuration object:

swift
import Nuke

func configureNuke() {
    var config = ImagePipeline.Configuration()
    config.dataCache = try? DataCache(name: "images")
    config.imageCache = ImageCache()
    
    // Aggressive memory caching for product feeds
    config.imageCache.costLimit = 500 * 1024 * 1024  // 500 MB
    
    // Fetch options: low priority for background preloading
    config.dataLoadingOptions.allowsCellularAccess = true
    
    ImagePipeline.shared = ImagePipeline(configuration: config)
}

Nuke's real power shines when you need image filtering, resizing, or progressive JPEG loading. It queues requests intelligently, cancels them when views disappear, and reports detailed metrics about cache hits and misses.

Downsampling: Preventing Memory Bloat

Even with aggressive caching, loading full-resolution images into memory is wasteful. A 12 MP photo from a camera is often 50+ MB on disk. If your app displays it in a 100×100 view, you've burned memory for 500× more pixels than you need.

Downsampling (also called image scaling) reduces the in-memory footprint drastically. iOS provides built-in APIs for this, but they're easy to get wrong.

Here's a helper that downsamples image data before decompression:

swift
import ImageIO
import UIKit

func downsampleImage(
    imageAt imageURL: URL,
    to targetSize: CGSize,
    scale: CGFloat = UIScreen.main.scale
) -> UIImage? {
    let imageSourceOptions = [
        kCGImageSourceShouldCache: false
    ] as CFDictionary
    
    guard let imageSource = CGImageSourceCreateWithURL(
        imageURL as CFURL,
        imageSourceOptions
    ) else {
        return nil
    }
    
    let maxDimensionInPixels = max(targetSize.width, targetSize.height) * scale
    let downsamplOptions = [
        kCGImageSourceCreateThumbnailFromImageIfAbsent: true,
        kCGImageSourceCreateThumbnailWithTransform: true,
        kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels
    ] as CFDictionary
    
    guard let downsampledImage = CGImageSourceCreateThumbnailAtIndex(
        imageSource,
        0,
        downsamplOptions
    ) else {
        return nil
    }
    
    return UIImage(cgImage: downsampledImage)
}

This approach uses ImageIO to decode only the pixels you need, avoiding the memory spike of loading the full image first. For a 4000×3000 image displayed at 100×100, you save roughly 99% of memory.

If you're using Nuke, downsampling is built in via the ImageProcessors API:

swift
let url = URL(string: "https://example.com/large-photo.jpg")!
let request = ImageRequest(
    url: url,
    processors: [
        ImageProcessors.Resize(size: CGSize(width: 100, height: 100))
    ]
)

Practical Caching Strategy: A Real-World Example

Let's walk through a complete setup used in a production e-commerce app:

  1. URLCache for HTTP-level caching: Configure a 500 MB in-memory, 2 GB disk cache at app launch.
  2. Nuke for image-level control: Use Nuke's ImagePipeline for priority queuing and memory monitoring.
  3. Downsampling for display: Process product thumbnails to 200×200 before display; full-res images only when tapped.
  4. User-triggered cache clear: Provide a "Clear cache" option in settings that calls ImageCache.shared.removeAll().
swift
// In your app initialization
func setupImageCaching() {
    // Configure URLCache
    let memoryCapacity = 500 * 1024 * 1024
    let diskCapacity = 2 * 1024 * 1024 * 1024
    let cache = URLCache(
        memoryCapacity: memoryCapacity,
        diskCapacity: diskCapacity,
        diskPath: "ProductImageCache"
    )
    URLCache.shared = cache
    
    // Configure Nuke
    var config = ImagePipeline.Configuration()
    config.imageCache = ImageCache()
    config.imageCache.costLimit = 300 * 1024 * 1024
    ImagePipeline.shared = ImagePipeline(configuration: config)
}

// In your product cell
struct ProductThumbnail: View {
    let product: Product
    
    var body: some View {
        LazyImage(url: product.thumbnailURL) { state in
            if let image = state.image {
                image
                    .resizable()
                    .scaledToFill()
            } else if state.isLoading {
                Color.gray.shimmer()
            } else {
                Image(systemName: "photo")
                    .foregroundColor(.gray)
            }
        }
        .frame(width: 200, height: 200)
        .clipped()
    }
}

Common Mistakes

  • Ignoring HTTP cache headers: A large URLCache won't help if your backend sends no-cache directives. Coordinate with backend teams on cache strategy.
  • Loading full-resolution images for thumbnails: Downsampling is not optional for performance. Always resize to the exact size you're displaying.
  • Not canceling in-flight requests: When a user scrolls past an image, cancel its fetch. Nuke does this automatically; URLSession does not.
  • Exceeding device storage: A 5 GB image cache is aggressive. On devices with 64 GB storage, this leaves little room for user data. Start conservative, measure, then adjust.
  • Forgetting to handle memory warnings: iOS issues memory warnings when RAM is tight. Listen for UIApplicationDidReceiveMemoryWarningNotification and clear caches if needed.

Key Takeaways

  • AsyncImage lacks production-grade caching; configure URLCache with 500 MB+ memory and 2+ GB disk capacity early in your app lifecycle.
  • Use libraries like Nuke for advanced features: priority queuing, automatic cancellation, and detailed metrics.
  • Downsample images before display using ImageIO or Nuke's processors to reduce memory footprint by up to 99%.
  • Ensure your backend sends proper cache-control headers; coordinate with your team on TTL and expiration policy.
  • Provide users with a cache-clearing option in settings and listen for iOS memory warnings to free space when needed.
  • Measure cache hit rates and memory usage in production via tools like Instruments; adjust capacity limits based on real data.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles