---
title: "Taming Compose Multiplatform Image Decoding on iOS"
published: true
description: "Build a memory-safe image pipeline for Compose Multiplatform on iOS — bridging Skia bitmaps to platform textures with a two-tier LRU cache and memory pressure hooks."
tags: kotlin, ios, architecture, mobile
canonical_url: https://blog.mvpfactory.co/taming-compose-multiplatform-image-decoding-on-ios
---
## What We Will Build
In this workshop, we will build a platform-aware `ImageLoader` for Compose Multiplatform that stops OOM crashes on iOS. You will wire up iOS memory pressure notifications via `expect/actual`, implement a two-tier LRU cache that evicts decoded bitmaps before encoded data, and bridge Skia bitmaps to platform-managed `CGImage`/`UIImage` textures so iOS can actually manage your app's memory footprint.
Let me show you a pattern I use in every image-heavy KMP project shipping to iOS.
## Prerequisites
- Kotlin Multiplatform project with Compose Multiplatform targeting iOS
- Familiarity with `expect/actual` declarations
- Xcode Instruments (for verifying the fix)
## The Problem: Skia Bypasses Everything iOS Gives You
On native iOS, `UIImage` + `ImageIO` gives you progressive JPEG decoding, OS-managed decoded bitmap caching, and automatic eviction under memory pressure. Compose Multiplatform's Skia backend throws all of that away. Skia decodes into its own `SkBitmap` allocations managed by Kotlin/Native's memory runtime. iOS has zero visibility into these allocations.
| 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 200+ images and resident memory just climbs. It never comes back down.
## Step 1: Bridge Memory Pressure via expect/actual
Here is the minimal setup to get this working. Without this, your cache is flying blind on iOS.
kotlin
// 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 non-negotiable for any image-heavy KMP app shipping to iOS.
## Step 2: Implement Two-Tier LRU Cache
The architecture uses two cache layers with asymmetric eviction — decoded bitmaps evict first, encoded data evicts second. Re-decoding from an in-memory byte array costs milliseconds. Re-downloading costs seconds and bandwidth.
kotlin
class TwoTierImageCache(
private val bitmapCacheMaxBytes: Long = 50L * 1024 * 1024, // 50MB decoded
private val encodedCacheMaxBytes: Long = 100L * 1024 * 1024 // 100MB encoded
) {
private val bitmapLru = LruCache(bitmapCacheMaxBytes)
private val encodedLru = LruCache(encodedCacheMaxBytes)
fun onMemoryPressure() {
bitmapLru.evictAll() // Drop expensive decoded data first
// Keep encoded — re-decode is cheaper than re-download
}
}
Size your bitmap tier conservatively — 50MB is a reasonable starting point on modern devices.
## Step 3: Bridge to Platform Textures Early
Instead of holding Skia bitmaps in Kotlin memory indefinitely, convert to platform-managed textures as early as possible:
kotlin
// 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. 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.
## Gotchas
**The Kotlin/Native GC trap.** The docs do not mention this, but large `ByteArray` allocations for encoded image data may not trigger GC quickly enough. The GC tracks object graph pressure, not raw byte volume. You can have hundreds of megabytes of unreachable encoded data sitting in the heap while the GC believes everything is fine.
kotlin
// After allocating large byte arrays for image data
GC.schedule() // Suggest a collection cycle
Here is the gotcha that will save you hours — the object count is low even though the byte volume is enormous. It is a blind spot in the GC's heuristics, and it will bite you in production.
**Instruments tells the real story.** Run Xcode Instruments' Allocations trace before and after these changes. Before: a staircase pattern where memory climbs with every scroll and never releases. After: a sawtooth — memory rises during scroll, then drops when the LRU evicts or the OS sends pressure warnings. Peak resident memory in image-heavy lists dropped enough to eliminate OOM kills entirely.
## Conclusion
Wire up memory pressure first. Then implement two-tier caching with asymmetric eviction. Finally, bridge to platform textures early — do not hold Skia bitmaps in the Kotlin heap longer than necessary. That is how you ship image-heavy Compose Multiplatform to iOS without the OOM kills.
For more on Kotlin/Native memory management, check the [official Kotlin/Native GC documentation](https://kotlinlang.org/docs/native-memory-manager.html) and the [Compose Multiplatform resources guide](https://www.jetbrains.com/compose-multiplatform/).
Top comments (0)