The pipeline: memory, disk, network, decode
When a row asks for a product image, the loader tries the cheapest source first:
- Memory cache: an image decoded a moment ago, ready to draw. Instant.
- Disk cache: the downloaded file from earlier, even from yesterday. It needs to be decoded, which takes a few milliseconds, off the main thread.
- 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.
inFlightmakes 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
srcsetpick one for the screen’s density.
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>
);
}Hint: The images are 4000 × 3000 pixels, shown at 120 × 120. How big is each one in memory, and how often is it downloaded?
Each cell downloads the original photo and decodes it at full size: 4000 × 3000 × 4 bytes = 48 MB per image. Twenty visible cells is almost 1 GB of bitmaps, far beyond what the system allows an app, so it’s killed. On top of that:
- No cache: scrolling a row off the screen and back downloads the photo again (that’s the 400 MB of mobile data).
- Main thread decoding (
UIImage(data:)decodes on first draw,BitmapFactoryruns on the main dispatcher here): every new row freezes scrolling for tens of milliseconds. - On the web, the browser downloads the 3 MB original for a 120-pixel box.
Use a loader that caches and downsamples to the displayed size, or ask the server for a small version:
func configure(with product: Product) {
imageTask = Task { [weak self] in
guard let self else { return }
let image = try? await loader.image(for: product.thumbnailURL,
pointSize: CGSize(width: 120, height: 120),
scale: traitCollection.displayScale)
if !Task.isCancelled { imageView.image = image }
}
}fun bind(product: Product) {
binding.image.load(product.thumbnailUrl) { // Coil: cache, downsample to the view, background decode
crossfade(true)
}
}<img src={`${product.imageBase}?w=240&fm=webp`} width={120} height={120} loading="lazy" decoding="async" alt="" />The best fix is on both sides: the server provides resized variants (next part), and the client still downsamples, because a “thumbnail” URL is sometimes larger than the view.
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?
Decoded bitmaps store every pixel: 4000 × 3000 = 12 million pixels × 4 bytes (red, green, blue, alpha) = 48 MB. The file's compressed size doesn't matter once it's decoded. Downsampled to 360 × 360 for a thumbnail, it's about 0.5 MB, a hundred times less.
- 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
In what order does an image loader check its sources?
What does downsampling do?
Why must the memory cache key include the target size?