iosNudge

How would you cache network responses and images in an iOS app?

MediumAsked oftenNetworkingPerformance 2 min read

TL;DR

Use two layers:

  1. HTTP cache (URLCache): follows server headers like Cache-Control and ETag. Mostly free.
  2. Image cache: an in-memory cache of decoded, downsampled images (NSCache), plus an optional disk cache.

Also make sure two requests for the same URL share one download.

Layer 1: HTTP caching with URLCache

URLSession uses URLCache.shared by default. It stores responses in memory and on disk. It respects the server's headers:

HeaderMeaning
Cache-Control: max-age=3600Fresh for one hour. No network needed.
Cache-Control: no-storeNever cache.
ETag: "abc"A version tag. The client sends If-None-Match: "abc" next time.
Last-ModifiedSame idea, with If-Modified-Since.

If the content did not change, the server replies 304 Not Modified with no body. URLSession returns the cached data as a normal 200 response.

let cache = URLCache(memoryCapacity: 20 * 1024 * 1024,
                     diskCapacity: 200 * 1024 * 1024)
let config = URLSessionConfiguration.default
config.urlCache = cache
config.requestCachePolicy = .useProtocolCachePolicy // follow the headers
let session = URLSession(configuration: config)

Other policies:

var request = URLRequest(url: url)
request.cachePolicy = .returnCacheDataElseLoad   // offline-first
request.cachePolicy = .reloadIgnoringLocalCacheData // always fresh

URLCache will not store very large responses (roughly more than 5% of the disk capacity).

Layer 2: an image cache

URLCache stores bytes. Turning bytes into a UIImage and decoding it is expensive. Cache the final image too.

actor ImageLoader {
    private let cache = NSCache<NSURL, UIImage>()
    private var inFlight: [URL: Task<UIImage, Error>] = [:]

    func image(for url: URL, pointSize: CGSize, scale: CGFloat) async throws -> UIImage {
        if let cached = cache.object(forKey: url as NSURL) { return cached }
        if let task = inFlight[url] { return try await task.value } // share the download

        let task = Task {
            let (data, _) = try await URLSession.shared.data(from: url)
            return try downsample(data, to: pointSize, scale: scale)
        }
        inFlight[url] = task
        defer { inFlight[url] = nil }

        let image = try await task.value
        cache.setObject(image, forKey: url as NSURL)
        return image
    }
}
  • NSCache is thread-safe and frees objects when memory is low.
  • The inFlight dictionary stops ten cells from downloading the same avatar ten times.

Downsampling

A 4000 x 3000 photo uses about 48 MB when decoded, even if it is shown at 100 x 100 points. Use ImageIO to decode a small version directly:

import ImageIO

func downsample(_ data: Data, to size: CGSize, scale: CGFloat) throws -> UIImage {
    let options = [kCGImageSourceShouldCache: false] as CFDictionary
    guard let source = CGImageSourceCreateWithData(data as CFData, options) else {
        throw URLError(.cannotDecodeContentData)
    }
    let maxPixels = max(size.width, size.height) * scale
    let thumbOptions = [
        kCGImageSourceCreateThumbnailFromImageAlways: true,
        kCGImageSourceShouldCacheImmediately: true,
        kCGImageSourceCreateThumbnailWithTransform: true,
        kCGImageSourceThumbnailMaxPixelSize: maxPixels
    ] as CFDictionary
    guard let cg = CGImageSourceCreateThumbnailAtIndex(source, 0, thumbOptions) else {
        throw URLError(.cannotDecodeContentData)
    }
    return UIImage(cgImage: cg)
}

UIImage.byPreparingThumbnail(ofSize:) (iOS 15) is a simpler option when you already have a UIImage.

Disk cache for images

For offline use or across launches, save files in the Caches directory. The system may delete them when storage is low, so treat them as optional.

What about AsyncImage?

It is simple, but it only relies on URLCache and has no memory cache of decoded images or downsampling. For long lists, use your own loader or a library such as Nuke or Kingfisher.

Common follow-up questions

  • Why NSCache and not a Dictionary? It is thread-safe and evicts items under memory pressure.
  • Can NSCache forget an item early? Yes, any time. Always be ready to load again.
  • How do you cache API JSON for offline mode? Use URLCache for simple cases, or store parsed models in SwiftData or files for real offline support.
  • How do you invalidate the cache? Rely on ETag/max-age, call removeCachedResponse(for:), or put a version in the URL.