Skip to content

Performance Engineering

Reducing Image Memory Pressure in a High-Volume iOS Content Engine

Stephane Lanfranchi
· 8 min read
iOS Performance ImageIO Memory UIKit

Case study overview

Role
iOS platform engineer investigating and reducing decoded-image memory pressure in the shared image-loading framework.
Context
Image-heavy generated applications reused one pipeline across thumbnails, cards, grids, carousels, headers, and full content pages.
Challenge
Avoid decoding original-resolution images for small views without breaking cache correctness, image transformations, cancellation, or existing callers.
Contribution
Added target-pixel APIs, ImageIO thumbnail decoding, size-aware rendition keys, disk-hit decoding, transformation compatibility, and a controlled profiling mode.
Outcome
An incremental display-sized path that preserved original bytes and observed 154.41 MiB versus 481.69 MiB persistent heap and anonymous VM in one controlled trace pair.
Abstract image downsampling and memory reduction diagram for an iOS image pipeline.

Image performance problems often hide in plain sight.

The UI looks normal. Images load. Scrolling works at first. But memory keeps growing, the app becomes heavier, and eventually the system starts to push back. On iOS, that can mean stutters, reloads, or crashes under memory pressure.

I worked on reducing image memory usage in an iOS content engine by changing how downloaded images were decoded for display. The core idea was simple: if the UI needs a small image, the app should not decode a huge image into memory just to draw it inside a small cell.

The implementation had to preserve the existing image-loading API while giving image-heavy screens a precise way to state what they actually needed.

The Context

GoodBarber apps display a lot of remote content: articles, videos, podcasts, galleries, configurable content sections, lists, and home screens. Many of those interfaces are image-heavy.

In a content engine, images are everywhere:

  • thumbnails in lists
  • cards on home screens
  • header images
  • media previews
  • grid items
  • carousel items
  • detail pages

The same image pipeline has to support many layouts and many image sizes. A remote image might be displayed as a small thumbnail in one place and a larger image somewhere else.

That makes image caching and decoding strategy important.

The Problem

The issue was not downloading images. The issue was decoded memory.

A compressed JPEG or PNG file on disk may be relatively small. But once decoded for display, it becomes raw pixel data in memory. The memory cost depends on pixel dimensions, not on the visual size of the image in the UI.

If the app downloads a large image and decodes it at full resolution, then displays it in a small 60-point cell, most of those decoded pixels are wasted. The user cannot see them, but the app still pays the memory cost.

In a scrolling list with many images, that cost adds up quickly.

The engine needed a way to decode images closer to their actual display size.

Decode to Display Size

The optimization I worked on introduced target-size decoding.

Instead of always decoding the original image dimensions, the caller can provide a target pixel size derived from the final display size and screen scale. The image pipeline can then use ImageIO thumbnail generation to decode a version that is much closer to what UIKit will draw.

This changes the memory profile significantly:

  • the original image can still be stored on disk
  • the displayed image is decoded at an appropriate size
  • memory cache entries can be specific to target dimensions
  • small cells no longer keep full-resolution decoded images in memory

The important distinction is between original data and display data.

The original downloaded bytes are still useful. They can be cached on disk, reused, or decoded differently later. But the image placed into a small cell does not need to be full resolution in memory.

The implementation uses ImageIO rather than first creating another full-size bitmap and resizing it afterward. The target dimensions are supplied in pixels, normally from the view’s point size multiplied by screen scale. CGImageSourceCreateThumbnailAtIndex decodes a transformed thumbnail whose maximum pixel dimension matches that request. If the source already fits, the normal decode path remains in place because there is nothing useful to downsample.

Animated images stay on the existing path. A single target-sized bitmap cannot preserve an animated asset’s frame semantics, so the optimization deliberately avoids treating GIF data like a static JPEG or PNG.

Image loading pipeline diagram showing a target pixel size flowing through a size-aware cache lookup, disk data or network download, ImageIO thumbnail decoding, a target-specific memory cache entry, and cancellation-safe delivery to a reused image view.
The original bytes remain reusable while each displayed rendition is decoded and cached for a specific pixel target.

Cache Keys Matter

Changing the decoded size creates a caching problem.

If the same URL is displayed at different sizes, the cache cannot treat every decoded result as identical. A 60-point thumbnail and a 300-point image may come from the same source URL, but they are not the same in memory.

That means the cache key needs to include the target size.

Without size-aware cache keys, one screen might accidentally receive an image decoded for another screen. That could cause blurry images, wasted memory, or unpredictable reuse.

So the optimization was not only about decoding. It also required making the cache model aware of display size.

The rendition key adds a normalized Thumbnail(WxH) suffix to the original cache key. Memory and disk lookups use that key consistently, so a thumbnail cannot silently replace the legacy full-resolution entry. On a disk hit, the stored encoded data can be decoded for the requested target. On a download, the image is downsampled before it reaches the cache or caller.

The pipeline also preserves existing transformation behavior. A delegate transformation still receives the original image. The transformed result is then scaled to the target dimensions, while an untransformed image can take the ImageIO path directly from its encoded bytes. This ordering avoids changing crop or processing semantics merely because a caller opted into lower memory use.

Keeping the API Compatible

One design goal was to avoid forcing every existing caller to change immediately.

In a mature engine, image loading is used in many places. A performance improvement is easier to adopt if it can be introduced incrementally. Callers that know the display size can opt into target-size decoding. Callers that do not can continue using the existing behavior.

That made the change safer.

It also made adoption more practical. The most image-heavy screens could be optimized first, where the memory impact was highest.

The view integration keeps the existing cancellation contract. Reused image views cancel their previous operation, and completion checks the combined operation before assigning a result. Target-sized decoding therefore changes the representation being delivered, not the protection against an older request overwriting a newly reused cell.

Comparing the Right Things

Performance work needs a controlled comparison. Looking at a memory graph after casual scrolling does not tell you much: the network cache, image sizes, and scroll history all vary.

I added a demonstration path that can load the same remote images in either legacy full-resolution mode or display-sized mode. That makes it possible to profile the same list, image set, and scrolling behavior with Instruments while changing only the decoding strategy.

The useful signals are the decoded image dimensions, the memory footprint after a repeatable scroll sequence, and whether cells receive a suitably sharp rendition at their target size. The result will vary with source images and device class, so the traces below are evidence from one comparison rather than a generic performance guarantee.

Comparison detailRecorded setup
WorkloadSame demo list, remote image set, and repeatable scrolling path
Variable changedFull-resolution decoding versus an explicit target pixel size
Trace durationApproximately 20 seconds per run
InstrumentAllocations
Reported metricPersistent All Heap & Anonymous VM at capture time
InterpretationOne run per mode; useful evidence, not a universal benchmark
Display-sized decoding
Instruments allocation timeline for display-sized decoding, reporting 154.41 MiB of persistent heap and anonymous virtual memory.
Full-resolution decoding
Instruments allocation timeline for full-resolution decoding, reporting 481.69 MiB of persistent heap and anonymous virtual memory.

Single-run comparison. Across these approximately 20-second traces, persistent All Heap & Anonymous VM measured 154.41 MiB with display-sized decoding and 481.69 MiB with full-resolution decoding. That is a 67.9% lower observed value in this trace pair, not a universal reduction claim.

This made the change easy to reason about and safe to review: full-resolution decoding remains the baseline, and target-size decoding is an explicit alternative for callers that know their rendering bounds.

Trade-Offs

There are trade-offs with target-size decoding.

If the target size is wrong, the image may be decoded too small and look soft. If callers request many nearly identical dimensions, the cache may accumulate unnecessary variants. Animated images do not use this path, and transformed images may require bitmap scaling when their transformed pixels no longer match the original encoded data. If callers do not know the final display size, they may not be able to benefit immediately.

So the API needs to make the optimization explicit. It should be used where the display size is known and stable enough to be meaningful.

That is why I prefer opt-in performance APIs for this kind of work. They let the engine improve memory usage without creating hidden visual regressions in screens that were not ready for the new behavior.

What Changed in the Pipeline

The important change was not a new cache or a visual compromise. It was allowing the image pipeline to retain original data on disk while decoding the in-memory representation for the context that requested it. That is a practical iOS performance pattern: reduce unnecessary pixel work, keep the API incremental, and verify the result with a repeatable workload.

Continue reading

Related engineering work