Skip to content
Commitwise
Menu
System Design

Mobile: image loading and caching

Design image loading for a mobile app, with a memory cache, a disk cache and the network, downsampling to the size actually shown, request deduplication, cancellation and the wrong-image-in-a-reused-row bug, prefetching for lists, and server-side resizing through a CDN, with code in Swift, Kotlin and TypeScript.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

A shop app shows a grid of product photos. On a new phone it scrolls fine. On a three-year-old Android phone it stutters, the photos flicker when you scroll back up, and after a minute of browsing the app crashes with an out-of-memory error. Users on a mobile data plan complain that the app used 400 MB in a day.

Every one of those problems is in the image pipeline: the photos are 4000 × 3000 pixels and shown at 120 × 120, nothing is cached properly, and the same image is downloaded again each time its row reappears. “Design image loading for a mobile app” is a common mobile system design question because it touches memory, disk, network, threading and the server, all in one feature.

New to some of these? Open what you need, or skip ahead.

Basics:What is a cache?

A cache keeps a copy of data somewhere faster than its original source, so repeated reads are quick. An image loader usually has two: a memory cache (instant, small, lost when the app closes) and a disk cache (fast, larger, survives restarts). The Caching lesson covers caches in general.

Basics:Compressed and decoded images

A JPEG file is compressed: a 12-megapixel photo might be 3 MB. To show it, the phone decodes it into a bitmap, which stores every pixel, usually 4 bytes each. That same photo becomes 4000 × 3000 × 4 = 48 MB in memory, whatever the file size was.

Part Not started

The pipeline: memory, disk, network, decode

When a row asks for a product image, the loader tries the cheapest source first:

  1. Memory cache: an image decoded a moment ago, ready to draw. Instant.
  2. Disk cache: the downloaded file from earlier, even from yesterday. It needs to be decoded, which takes a few milliseconds, off the main thread.
  3. Network: download it, save the file to the disk cache, decode it, put the result in the memory cache.

The most important trick is downsampling: decode the image at the size it will be shown, not the size of the file. A 120 × 120 point thumbnail on a 3× screen needs 360 × 360 pixels, about 0.5 MB, instead of 48 MB for the full photo. That’s the difference between a smooth grid and an out-of-memory crash.

A production image pipeline (Kingfisher, Nuke and SDWebImage on iOS; Coil, Glide and Fresco on Android) is a staged, keyed, cancellable pipeline: memory cache of decoded bitmaps (LRU, sized as a fraction of available memory, cleared on memory warnings and onTrimMemory), disk cache of encoded data (LRU by size, respecting or overriding HTTP cache headers), network via a shared HTTP client, then decode and transform on background threads. The memory cache key must include the target size and transformations, not just the URL. Decoding with downsampling (CGImageSourceCreateThumbnailAtIndex with kCGImageSourceThumbnailMaxPixelSize, BitmapFactory.Options.inSampleSize or ImageDecoder.setTargetSize) avoids ever allocating the full bitmap. Concurrent requests for the same key coalesce into one download and decode. Hardware bitmaps (Android) and avoiding main-thread decoding (iOS, where UIImage(data:) decodes lazily at first draw unless forced with preparingForDisplay()) keep scrolling smooth.

actor ImageLoader {
    private let memory = NSCache<NSString, UIImage>()
    private let disk: DiskCache
    private var inFlight: [String: Task<UIImage, Error>] = [:]

    init(disk: DiskCache) {
        self.disk = disk
        memory.totalCostLimit = 100 * 1024 * 1024           // ~100 MB of decoded pixels
    }

    func image(for url: URL, pointSize: CGSize, scale: CGFloat) async throws -> UIImage {
        let maxPixels = Int(max(pointSize.width, pointSize.height) * scale)
        let key = "\(url.absoluteString)#\(maxPixels)"       // the size is part of the key

        if let cached = memory.object(forKey: key as NSString) { return cached }
        if let running = inFlight[key] { return try await running.value }   // coalesce

        let task = Task<UIImage, Error> {
            let data = try await disk.data(for: url) ?? download(url)
            return try downsample(data, maxPixelSize: maxPixels)              // never decode full size
        }
        inFlight[key] = task
        defer { inFlight[key] = nil }

        let image = try await task.value
        memory.setObject(image, forKey: key as NSString, cost: image.decodedByteCount)
        return image
    }

    private func download(_ url: URL) async throws -> Data {
        let (data, _) = try await URLSession.shared.data(from: url)
        try await disk.store(data, for: url)
        return data
    }
}

func downsample(_ data: Data, maxPixelSize: Int) throws -> UIImage {
    let source = CGImageSourceCreateWithData(data as CFData, [kCGImageSourceShouldCache: false] as CFDictionary)!
    let options: [CFString: Any] = [
        kCGImageSourceCreateThumbnailFromImageAlways: true,
        kCGImageSourceThumbnailMaxPixelSize: maxPixelSize,
        kCGImageSourceCreateThumbnailWithTransform: true,     // apply EXIF orientation
        kCGImageSourceShouldCacheImmediately: true,           // decode now, off the main thread
    ]
    guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
        throw ImageError.decodeFailed
    }
    return UIImage(cgImage: cgImage)
}
// In practice: Coil. Its pipeline does memory → disk → network, downsampling to the view's size
val imageLoader = ImageLoader.Builder(context)
    .memoryCache { MemoryCache.Builder().maxSizePercent(context, 0.20).build() }   // 20% of app memory
    .diskCache {
        DiskCache.Builder()
            .directory(context.cacheDir.resolve("image_cache"))
            .maxSizeBytes(250L * 1024 * 1024)
            .build()
    }
    .crossfade(true)
    .build()

@Composable
fun ProductThumbnail(url: String, modifier: Modifier = Modifier) {
    AsyncImage(
        model = ImageRequest.Builder(LocalContext.current)
            .data(url)
            .size(360, 360)                     // 120.dp at 3x; omit to let Coil measure the view
            .build(),
        contentDescription = null,
        contentScale = ContentScale.Crop,
        modifier = modifier.size(120.dp),
    )
}

// What downsampling means underneath, with the platform decoder
fun decodeSampled(bytes: ByteArray, reqWidth: Int, reqHeight: Int): Bitmap {
    val source = ImageDecoder.createSource(ByteBuffer.wrap(bytes))
    return ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
        val scale = minOf(reqWidth.toFloat() / info.size.width, reqHeight.toFloat() / info.size.height, 1f)
        decoder.setTargetSize((info.size.width * scale).toInt(), (info.size.height * scale).toInt())
    }
}
// Web or React Native web: the browser decodes, so the job is to request the right size
function ProductThumbnail({ imageId }: { imageId: string }) {
  const base = `https://img.example.com/${imageId}`;
  return (
    <img
      src={`${base}?w=240&fm=webp`}
      srcSet={`${base}?w=120&fm=webp 120w, ${base}?w=240&fm=webp 240w, ${base}?w=360&fm=webp 360w`}
      sizes="120px"
      width={120}
      height={120}
      loading="lazy"            // don't load until near the viewport
      decoding="async"          // decode off the main thread
      alt=""
    />
  );
}
  • The memory key includes the size. The same photo as a 120-point thumbnail and as a full-screen image are two different decoded bitmaps.
  • inFlight makes sure that if five rows ask for the same image at once, it’s downloaded and decoded once.
  • Downsampling happens while decoding, so the full 48 MB bitmap is never created.
  • On Android, Coil (or Glide) does all of this for you, and measures the view to pick the size. On the web, the browser decodes, so you ask the server for the right size and let srcset pick one for the screen’s density.
Spot the bugThe product grid that crashes after a minute

Tap the line with the bug.

final class ProductCell: UICollectionViewCell {
    let imageView = UIImageView()

    func configure(with product: Product) {
        Task {
            let (data, _) = try await URLSession.shared.data(from: product.imageURL)
            imageView.image = UIImage(data: data)
        }
    }
}
class ProductViewHolder(private val binding: ItemProductBinding) : RecyclerView.ViewHolder(binding.root) {
    fun bind(product: Product, scope: CoroutineScope) {
        scope.launch {
            val bytes = withContext(Dispatchers.IO) { URL(product.imageUrl).readBytes() }
            binding.image.setImageBitmap(BitmapFactory.decodeByteArray(bytes, 0, bytes.size))
        }
    }
}
function ProductCard({ product }: { product: Product }) {
  return (
    <div className="card">
      <img src={product.originalImageUrl} width={120} height={120} alt="" />
      <p>{product.name}</p>
    </div>
  );
}

Predict the output

A 12-megapixel photo (4000 × 3000) is a 2.5 MB JPEG. The app decodes it at full size into an ARGB_8888 bitmap. How much memory does the bitmap use?

  • Stages: memory (decoded, LRU by byte cost, cleared on memory pressure) → disk (encoded, LRU by size) → network → decode/transform off the main thread.
  • Memory key = URL + target size + transformations. Decoded size is w × h × 4 bytes, independent of file size.
  • Downsample during decode (CGImageSourceCreateThumbnailAtIndex, ImageDecoder.setTargetSize, inSampleSize); never allocate the full bitmap.
  • Coalesce identical in-flight requests; force decode off-main (preparingForDisplay, kCGImageSourceShouldCacheImmediately).
  • Use Kingfisher/Nuke/SDWebImage or Coil/Glide in practice; know what they do.

Check this part

  1. In what order does an image loader check its sources?

  2. What does downsampling do?

  3. Why must the memory cache key include the target size?

An image request checks a memory cache of decoded bitmaps, then a disk cache of downloaded files, then the network. Decoding happens off the main thread and downsamples to the size the view actually shows, because memory is width × height × 4 bytes regardless of file size. Identical requests in flight share one download.

Part Not started

Lists, prefetching and the server side

Lists reuse rows: when a row scrolls off the top, the same row object is filled with new data and appears at the bottom. That causes a classic bug. Row 3 starts loading the image for product A, the user scrolls, the row is reused for product K, and then A’s image arrives and is put into the row now showing K. The user sees the wrong photo.

Two fixes, used together:

  • Cancel the old request when the row is reused or leaves the screen.
  • Check that the image that arrived is still the one this row wants before showing it.

Image libraries do both automatically, which is a big reason to use one.

To make scrolling feel instant, the app can also prefetch: start loading images for the rows just below the screen, so they’re in the cache by the time they appear.

Correctness in lists: tie each request to the view’s current identity (cancel on prepareForReuse/onViewRecycled, or in Compose and SwiftUI let the request’s lifetime follow the composable or view via AsyncImage/LazyImage, keyed by item id), and guard the final assignment with an identity check. Prefetching (UICollectionViewDataSourcePrefetching, Compose LazyListState lookahead, Coil’s enqueue of prefetch requests into the disk cache only) should be bounded, lower priority than visible requests, and cancelled when the scroll direction changes. On the server, a resizing CDN (or an image service behind a CDN) produces variants from URL parameters (width, format, quality), with an allow-list of sizes to keep cache hit rates high and prevent abuse, content negotiation (AVIF/WebP via Accept), and long Cache-Control: public, max-age=31536000, immutable on content-addressed URLs, so an updated image gets a new URL instead of a cache purge.

final class ProductCell: UICollectionViewCell {
    let imageView = UIImageView()
    private var imageTask: Task<Void, Never>?
    private var representedId: ProductId?

    func configure(with product: Product, loader: ImageLoader) {
        representedId = product.id
        imageView.image = placeholder
        imageTask = Task { [weak self] in
            let image = try? await loader.image(for: product.thumbnailURL,
                                                pointSize: CGSize(width: 120, height: 120), scale: 3)
            guard let self, !Task.isCancelled, self.representedId == product.id else { return }  // still ours?
            self.imageView.image = image
        }
    }

    override func prepareForReuse() {
        super.prepareForReuse()
        imageTask?.cancel()               // the row is about to show something else
        imageTask = nil
        representedId = nil
        imageView.image = nil
    }
}
// Compose + Coil: the request is tied to the composable for this key.
// When the item scrolls away or the url changes, the old request is cancelled.
@Composable
fun ProductGrid(products: List<Product>) {
    val context = LocalContext.current
    val gridState = rememberLazyGridState()
    LazyVerticalGrid(columns = GridCells.Adaptive(120.dp), state = gridState) {
        items(products, key = { it.id.value }) { product ->
            AsyncImage(model = product.thumbnailUrl, contentDescription = product.name,
                modifier = Modifier.size(120.dp), contentScale = ContentScale.Crop)
        }
    }

    // Prefetch the next 10 images into the disk cache while the user scrolls
    LaunchedEffect(gridState, products) {
        snapshotFlow { gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 }
            .distinctUntilChanged()
            .collect { last ->
                products.drop(last + 1).take(10).forEach { p ->
                    context.imageLoader.enqueue(ImageRequest.Builder(context).data(p.thumbnailUrl)
                        .memoryCachePolicy(CachePolicy.DISABLED)   // disk only: don't push visible items out
                        .build())
                }
            }
    }
}
// React: ignore results for an old url when the component has moved on
function useImage(url: string) {
  const [src, setSrc] = useState<string | null>(null);
  useEffect(() => {
    const controller = new AbortController();
    setSrc(null);
    fetch(url, { signal: controller.signal })
      .then((r) => r.blob())
      .then((blob) => setSrc(URL.createObjectURL(blob)))
      .catch(() => {});                                   // aborted or failed
    return () => controller.abort();                      // cancel when url changes or on unmount
  }, [url]);
  return src;
}

Production puzzles

Predict the output

Product images are served at https://cdn.example.com/products/42.jpg with Cache-Control: max-age=31536000. The merchant replaces the photo. What do users see?

Predict the output

The image service resizes to any width passed in ?w=. A scraper requests w=1, w=2, … w=4000 for every product image. What happens?

Swift note

UIImage(data:) and UIImage(contentsOfFile:) return quickly because they don’t decode; decoding happens on the main thread the first time the image is drawn, which is where scroll hitches come from. Use image.preparingForDisplay() (iOS 15+) or byPreparingThumbnail(ofSize:) on a background task, or decode through CGImageSource with kCGImageSourceShouldCacheImmediately as in the loader above.

Kotlin note

onTrimMemory and onLowMemory are signals to shrink the memory cache. Coil and Glide register for them automatically; a hand-written LruCache must call trimToSize itself. On Android 8+, hardware bitmaps (Bitmap.Config.HARDWARE, Coil’s default where possible) keep pixel data in graphics memory, reducing heap pressure, but can’t be read pixel by pixel, so transformations that inspect pixels need a software bitmap.

Exercisemedium

Size the caches

A photo feed shows full-width images: 390 points wide on a 3× iPhone, about 1170 × 1170 pixels each. Users scroll through about 300 images per session and often come back to the same posts the next day. Suggest sizes for the memory and disk caches, and explain your reasoning.

›

Compute the memory for one decoded image, then ask how many need to be instant. For disk, think about compressed sizes and “tomorrow”.

SolutionTry it yourself first
  • One decoded image: 1170 × 1170 × 4 ≈ 5.5 MB.
  • Memory cache: enough for what’s on screen plus a few screens around it, so scrolling back up is instant: about 15 to 20 images ≈ 80 to 110 MB. In practice, a percentage of the app’s memory (15–25%), cleared on memory warnings.
  • Disk cache: compressed images at that size are about 150–250 KB in WebP. 300 per day for a few days ≈ 1,000 images ≈ 200 MB. Something like 250 MB, evicting the least recently used, keeps yesterday’s posts instant without filling the phone.

Also: ask the server for the right size (1170 wide, WebP or AVIF), so neither cache stores pixels nobody sees.

  • Reused rows: cancel on reuse/recycle, tie request lifetime to the view or composable key, and verify identity before assigning.
  • Prefetch a bounded window ahead at lower priority, into the disk cache; cancel on direction change.
  • Resizing CDN: allow-listed sizes, Accept-based AVIF/WebP, signed or rounded parameters.
  • Content-addressed URLs with max-age=31536000, immutable; mutable URLs need short lifetimes and ETag revalidation.
  • iOS: avoid lazy main-thread decode (preparingForDisplay). Android: trim caches on onTrimMemory; hardware bitmaps.

Check this part

  1. Why can a reused list row show the wrong image?

  2. How do you update an image cached with a one-year max-age?

  3. Why allow-list image widths on a resizing service?

In scrolling lists, cancel requests for rows that leave the screen and check that a finished image still belongs to the row, or reused rows show the wrong image. Prefetch the rows just ahead. On the server, a resizing CDN serves each size and format the clients ask for, with long cache lifetimes and immutable URLs.

Key takeaways

  • Check memory, then disk, then the network.
  • Decode images at the size they’re shown; full-size bitmaps crash apps.
  • Cancel requests for reused rows and check the image still belongs there.
  • Ask the server for the right size and format.
  • Keyed, cancellable pipeline with coalescing, off-main decoding and downsampling.
  • Memory caches by byte cost, cleared under pressure; disk caches by size.
  • Bounded, low-priority prefetching tied to scroll position.
  • Resizing CDN with allow-listed sizes and immutable, content-addressed URLs.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.