Fast Thumbnails with CGImageSource
When working with images, we rarely need to show them at full size — most often ist’s rather as a thumbnail or preview. Using fully loaded images for this is quite slow, so we implemented a thumbnail cache.
While modernizing this component, I remembered user reports about slow image loading. When they opened documents with photos, the app would stall for a moment, before becoming buttery-smooth again. The caching was clearly working, but the initial loading was problematic.
The Outset
I took a quick measure of the actual performance and was shocked! A normal 12MP JPEG iPhone photo took a whopping 710 milliseconds to downsize on my M4 Pro Mac. Wait. What? Time to investigate!
The code to do this looked somewhat like this:
let image = NSImage(data: imageData)
let thumbnail = NSImage(size: size, flipped: false) { rect in
image.draw(in: rect)
}
Creating the image is fast, but resizing takes most of the time. My guess is that image data remains untouched until an actual operation occurs. Regardless, that number seemed wrong. How could a current, high-end Mac take so long to make a thumbnail? I was clearly doing something wrong.
Out of curiosity, I ran the same test on the iOS Simulator using UIGraphicsImageRenderer
. To my surprise, this was much faster: The same image took 210ms to resize. iOS being more than 3x faster than macOS for the same operation was puzzling. The Mac should handle this just as efficiently. Hell, the iOS code was running in a simulation inside macOS.
My head turned from “can this be faster?” to “how can this be faster?”
First Discovery: UIImage.preparingThumbnail()
A bit of googling later, I discovered a faster approach! UIImage
has a preparingThumbnail(of: size)
function that downscales large images more efficiently:
let image = UIImage(data: imageData)!
let thumbnail = image.preparingThumbnail(of: targetSize)
Using this function, the same image took just 130ms in the Simulator—dramatically faster! I did a quality comparison and it showed nearly identical quality.
Surprisingly (or rather not), this API doesn't exist for NSImage
on macOS. 😑
So now we had iOS at 130ms vs. macOS at 710ms — more than 5x faster on iOS.
The real thing: CGImageSource
Further googling didn't reveal much more, so I turned to Hopper and did some digging. The thumbnail function likely used lower-level APIs that might also be accessible on macOS.
And indeed, I found there's a Core Graphics API that does exactly what I needed: CGImageSourceCreateThumbnailAtIndex()
. Crucially, it’s public and available on macOS too!
After some experimentation to understand how to use it reliably, the solution turned out to be quite simple:
let source = CGImageSourceCreateWithData(imageData as CFData, nil)!
let cgThumb = CGImageSourceCreateThumbnailAtIndex(source, 0, [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: max(size.width, size.height)
] as CFDictionary)!
let thumbnail = UIImage(cgImage: cgThumb) // or NSImage on macOS
(Beware the force-unwraps, you may want to handle this more nicely.)
The parameters are documented, but the optimal combination is not. Here's what I learned:
kCGImageSourceCreateThumbnailFromImageAlways
: Required for formats that might include embedded thumbnails but usually don't (like HEIC). There's an…IfAbsent
variant, but it does not seem to be sufficient.kCGImageSourceThumbnailMaxPixelSize
: Specifies the largest dimension of the desired thumbnail; the returned image will then be equal or smaller than this.
The first test showed incredible improvements: The same 12MP JPEG image now took just about 26ms on macOS. That’s almost 30 times faster than the naive approach. iOS had similar times, bringing the two platforms on par.
More Measurements
However, I noticed some inconsistencies in the measurements. It seems there’s always a "cold" vs "warm" state for all these operations—reading data, getting size, rendering thumbnails. This affects every step regardless of which image you load. I created HEIC and PNG versions of my JPEG and re-measured more carefully.
iOS 26 Simulator:
UIImageRenderer
- HEIC: 83ms (224ms cold)
- JPEG: 105ms (161ms cold)
- PNG: 363ms (534ms cold)
CGImageSource (iOS)
- HEIC: 52ms (185ms cold)
- JPEG: 24ms ( 81ms cold)
- PNG: 95ms (118ms cold)
macOS 26:
NSImage
- HEIC: 637ms (765ms cold)
- JPEG: 628ms (709ms cold)
- PNG: 675ms (713ms cold)
CGImageSource (macOS)
- HEIC: 43ms (151ms cold)
- JPEG: 16ms ( 73ms cold)
- PNG: 145ms (183ms cold)
The first image always took longer than subsequent ones, even when loading entirely different images. I’m speculating, but it seems plausible the cold/warm performance difference must come from some internal state of CoreGraphics – on-demand loading, or caching, or something.
Anyhow, you can see that UIImageRenderer
and UIImage
seem much better optimized than NSImage
— which makes sense given the number of developers and apps, and tighter memory and power constraints on iOS. Though it also paints a sad image for the Mac platform, being left without a convenient thumbnail API that’s been available since iOS 15, four years ago.
Fortunately, CGImageSource
delivers massive improvements across the board, especially on macOS:
- JPEG on macOS: 40x faster (16ms vs. 628ms)
- PNG on macOS: 4x faster (145ms vs. 675ms)
- PNG on iOS: 4x faster (95ms vs. 363ms)
(Please note that none of these measures are really scientific, and that you may see different results).
Bonus: Instant Image Dimensions
CGImageSource
turned out to be my discovery of that day. You can get image dimensions almost instantly without loading anything:
// Get image dimensions without loading the full image
let source = CGImageSourceCreateWithData(data as CFData, nil)!
if let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
let width = properties[kCGImagePropertyPixelWidth] as? Int,
let height = properties[kCGImagePropertyPixelHeight] as? Int {
print("Image size: \(width) x \(height)")
}
This takes just 2-4ms on macOS—perfectly reasonable for reading a metadata header. I tested it with truncated image data and it still worked , making it perfect for setting correct aspect ratios on placeholders.
Bonus 2: For the Hopper enthusiasts
Turns out, UIImage
actually has two completely parallel thumbnail implementations: one for images backed by a CGImage
and one for images backed by a CGImageSource
. Images initialized with file paths get a CGImageSource
, while those initialized with Data
only get CGImage
. (Not sure why, since Data
could also be a source.)
The CGImageSource
case uses the approach described above with the similar options. Compared to my sample, there are two more being set that did not have any effect in my testing, which is why I left them out:
kCGImageSourceCreateThumbnailFromImageIfAbsent: true
kCGImageSourceCreateThumbnailWithTransform: 0
But if you hit the other path, it uses a private CGImageCreateThumb(cgImage, maxSize)
function that does the same thing on the loaded image data — but in my testing it's actually slower! For example, HEIC on iOS takes 82ms instead of 57ms.
Impact
While ~100ms is much better, it could still feel sluggish when rendering multiple images simultaneously. In my refined implementation, I made thumbnail generation async while keeping size calculation synchronous (2-4ms depending on format). Having the aspect ratio available simplifies layout code while keeping the synchronous performance impact negligible.