Taming Compose Multiplatform Image Decoding on iOS: Skia Codec Pitfalls, NSImage Bridging, and the Memory Pipeline That Stopped Our OOM Crashes
TL;DR: Compose Multiplatform uses Skia for image decoding on iOS, completely bypassing NSCache, ImageIO progressive decoding, and the OS memory pressure system. In image-heavy lists, this leads to unbounded memory growth and OOM kills. The fix: a custom ImageLoader that bridges Skia bitmaps to platform-managed textures, hooks into iOS memory pressure notifications via expect/actual, and implements a two-tier LRU cache that respects both Kotlin/Native’s GC and ARC lifecycle.
The problem nobody warns you about
The moment you ship an image-heavy Compose Multiplatform screen to iOS, you’re running on borrowed time. I’ve watched teams burn days debugging this because they assume Skia’s image pipeline on iOS behaves like UIImage + ImageIO. It doesn’t.
On native iOS, loading an image through UIImage or Kingfisher gives you a well-integrated pipeline: ImageIO handles progressive JPEG decoding, the system manages a behind-the-scenes decoded bitmap cache, and under memory pressure, iOS can evict decoded data and re-decode from the compressed source. Your app cooperates with the OS.
Compose Multiplatform’s Skia backend throws all of that away. Skia decodes images into its own SkBitmap allocations, managed by Kotlin/Native’s memory runtime. iOS has zero visibility into these allocations. When the system sends memory pressure notifications, it can’t reclaim anything. From the OS perspective, your app’s image memory is just opaque Kotlin heap.
What this actually looks like
| Aspect | Native iOS (UIImage/ImageIO) | Compose Multiplatform (Skia) |
|---|---|---|
| Decoded bitmap cache | OS-managed, evictable | Manual, non-evictable |
| Memory pressure response | Automatic purge | None by default |
| Progressive decoding | Built-in via ImageIO | Not supported |
| Texture upload | GPU-managed via CALayer | CPU-side SkBitmap |
| GC interaction | ARC (deterministic) | Kotlin/Native GC (tracing) |
Scroll through a list of 200+ images and resident memory just climbs. It never comes back down because there’s no eviction mechanism tied to the platform.
Building the fix: a platform-aware ImageLoader
Step 1: the expect/actual memory pressure bridge
First, you need iOS memory warnings to reach your Kotlin code:
// commonMain
expect class MemoryPressureMonitor() {
fun onLowMemory(callback: () -> Unit)
}
// iosMain
actual class MemoryPressureMonitor {
actual fun onLowMemory(callback: () -> Unit) {
NSNotificationCenter.defaultCenter.addObserverForName(
UIApplicationDidReceiveMemoryWarningNotification,
null, NSOperationQueue.mainQueue
) { _ -> callback() }
}
}
This is the single most important piece. Without it, your cache is flying blind on iOS.
Step 2: two-tier LRU cache
The architecture uses two cache layers that respect different lifecycle semantics:
class TwoTierImageCache(
private val bitmapCacheMaxBytes: Long = 50L * 1024 * 1024, // 50MB decoded
private val encodedCacheMaxBytes: Long = 100L * 1024 * 1024 // 100MB encoded
) {
private val bitmapLru = LruCache<String, ImageBitmap>(bitmapCacheMaxBytes)
private val encodedLru = LruCache<String, ByteArray>(encodedCacheMaxBytes)
fun onMemoryPressure() {
bitmapLru.evictAll() // Drop expensive decoded data first
// Keep encoded — re-decode is cheaper than re-download
}
}
Under memory pressure, evict decoded bitmaps first and keep encoded data. Re-decoding from an in-memory byte array costs milliseconds. Re-downloading costs seconds and bandwidth. This asymmetry should drive every caching decision you make here.
Step 3: bridging to platform textures
Instead of holding Skia bitmaps in Kotlin memory indefinitely, convert them to platform-managed textures as early as possible:
// iosMain
actual fun toPlatformTexture(bitmap: ImageBitmap): PlatformImage {
val cgImage = bitmap.toCGImage() // Convert to CoreGraphics
// Now iOS can manage this memory via its own lifecycle
return PlatformImage(UIImage(cgImage))
}
Once the image lives as a CGImage/UIImage, iOS regains visibility and can participate in memory management decisions.
The Kotlin/Native GC trap
There’s a subtle interaction with Kotlin/Native’s tracing GC that caught me off guard. Large ByteArray allocations for encoded image data may not trigger collection quickly enough because the GC tracks Kotlin object graph pressure, not raw byte volume. You need to hint at the pressure:
// After allocating large byte arrays for image data
GC.schedule() // Suggest a collection cycle
Without this, you can have hundreds of megabytes of unreachable encoded data sitting in the heap while the GC believes everything is fine. The object count is low even though the byte volume is enormous. It’s a blind spot in the GC’s heuristics, and it will bite you in production.
What Instruments reveals
Run Xcode Instruments’ Allocations trace before and after these changes. The difference is stark. Before the fix, you’ll see a staircase pattern where memory climbs with every scroll and never releases. After implementing the two-tier cache with memory pressure hooks, the graph becomes a sawtooth: memory rises during scroll, then drops when the LRU evicts or the OS sends pressure warnings. In my testing, peak resident memory in image-heavy lists dropped enough to eliminate OOM kills entirely.
What to do right now
Wire up memory pressure first. Use expect/actual to bridge UIApplicationDidReceiveMemoryWarningNotification into your shared code. This is non-negotiable for any image-heavy KMP app shipping to iOS. Without it, your cache can’t cooperate with the OS.
Then implement two-tier caching with asymmetric eviction. Decoded bitmaps evict first, encoded data evicts second. The cost gap between re-decode and re-download makes this the only rational strategy. Size your bitmap tier conservatively; 50MB is a reasonable starting point on modern devices.
Finally, bridge to platform textures early. Don’t hold Skia bitmaps in the Kotlin heap longer than necessary. Convert to CGImage/UIImage and let iOS manage the GPU texture lifecycle. This single change gives the OS back its ability to manage your app’s memory footprint under pressure.
The full pipeline: download -> encoded LRU -> decode via Skia -> convert to platform texture -> bitmap LRU -> evict under pressure. Every stage has a clear owner and a clear eviction policy. That’s how you ship image-heavy Compose Multiplatform to iOS without the OOM kills.