Environment
react-native-compressor version: 2.0.2 (same root cause present since at least 1.16.0)
- Platform: iOS (reproduced), Android has a related but narrower issue (see below)
- Devices: iPhone 11, iPhone 13 (both 4GB RAM) — reproduced via Xcode Instruments Allocations/VM Tracker
- React Native: 0.83.6, Expo SDK 55
Description
Image.compress() on iOS crashes the app (OOM / jetsam kill) when compressing several photos concurrently on lower-RAM devices, even though each individual photo is well within normal camera resolution (12MP HEIC).
Root cause
Both manualCompressHandler and autoCompressHandler in ImageCompressor.swift decode the source image at full native resolution before any downsampling happens:
loadImage() → UIImage(contentsOfFile:) / UIImage(data:) — decodes the full image.
scaleAndRotateImage() — draws that full-resolution image into a second full-resolution CGContext just to apply EXIF orientation:
guard let context = CGContext(data: nil, width: Int(image.size.width), height: Int(image.size.height), ...)
context.draw(cgImage, in: ...)
- Only then does
manualResize() / the inline resize in autoCompressHandler shrink the image down to maxWidth/maxHeight.
For a 12MP HEIC photo, step 2 alone needs roughly two ~49MB RGBA buffers in memory simultaneously (source + redraw target) — before the requested resize has done anything. An app that compresses a small batch of photos concurrently (e.g. an upload queue of 5) can hit iOS's jetsam memory limit purely from this decode/rotate step on 4GB-RAM devices, well before the resize logic runs.
Notably, getImageMetaData in the same module already avoids this exact problem for a different code path, with this comment:
// Use CGImageSourceCreateWithURL to avoid loading entire file into memory (prevents OOM crashes for large images)
...it just isn't applied to the actual compress path.
Android
manualCompressImage() in ImageCompressor.kt has the analogous issue — loadImage() uses BitmapFactory.decodeFile() with no downsampling, unlike autoCompressImage(), which already downsamples correctly via BitmapFactory.Options.inSampleSize.
Reproduction
- Pick 5+ HEIC photos from the camera roll on an iPhone with 4GB RAM (11, 13, 13 mini).
- Call
Image.compress() on all of them concurrently (compressionMethod: 'manual' or 'auto', doesn't matter — both hit the same decode path).
- Watch memory in Xcode Instruments (Allocations / VM Tracker) — peak RSS spikes well past what the resulting compressed images would ever need, and the app is terminated by the OS on 4GB devices.
Suggested fix
Decode via CGImageSourceCreateThumbnailAtIndex with kCGImageSourceThumbnailMaxPixelSize + kCGImageSourceCreateThumbnailWithTransform: true, which downsamples and applies EXIF orientation in a single native decode step, without ever materializing the full-resolution bitmap. On Android, reuse the inSampleSize downsampling autoCompressImage() already does, in manualCompressImage() too.
I've verified this fix compiles cleanly on both platforms and resolves the crash in a downstream app (patched via patch-package, then confirmed with real device testing). Opening a PR with the fix now — will link it here.
While fixing this I also found and fixed a separate latent bug in copyExifInfo() that causes double-rotation once this decode path is used with a source that has a non-.up EXIF orientation. Details in the PR description.
Environment
react-native-compressorversion: 2.0.2 (same root cause present since at least 1.16.0)Description
Image.compress()on iOS crashes the app (OOM / jetsam kill) when compressing several photos concurrently on lower-RAM devices, even though each individual photo is well within normal camera resolution (12MP HEIC).Root cause
Both
manualCompressHandlerandautoCompressHandlerinImageCompressor.swiftdecode the source image at full native resolution before any downsampling happens:loadImage()→UIImage(contentsOfFile:)/UIImage(data:)— decodes the full image.scaleAndRotateImage()— draws that full-resolution image into a second full-resolutionCGContextjust to apply EXIF orientation:manualResize()/ the inline resize inautoCompressHandlershrink the image down tomaxWidth/maxHeight.For a 12MP HEIC photo, step 2 alone needs roughly two ~49MB RGBA buffers in memory simultaneously (source + redraw target) — before the requested resize has done anything. An app that compresses a small batch of photos concurrently (e.g. an upload queue of 5) can hit iOS's jetsam memory limit purely from this decode/rotate step on 4GB-RAM devices, well before the resize logic runs.
Notably,
getImageMetaDatain the same module already avoids this exact problem for a different code path, with this comment:...it just isn't applied to the actual compress path.
Android
manualCompressImage()inImageCompressor.kthas the analogous issue —loadImage()usesBitmapFactory.decodeFile()with no downsampling, unlikeautoCompressImage(), which already downsamples correctly viaBitmapFactory.Options.inSampleSize.Reproduction
Image.compress()on all of them concurrently (compressionMethod: 'manual'or'auto', doesn't matter — both hit the same decode path).Suggested fix
Decode via
CGImageSourceCreateThumbnailAtIndexwithkCGImageSourceThumbnailMaxPixelSize+kCGImageSourceCreateThumbnailWithTransform: true, which downsamples and applies EXIF orientation in a single native decode step, without ever materializing the full-resolution bitmap. On Android, reuse theinSampleSizedownsamplingautoCompressImage()already does, inmanualCompressImage()too.I've verified this fix compiles cleanly on both platforms and resolves the crash in a downstream app (patched via
patch-package, then confirmed with real device testing). Opening a PR with the fix now — will link it here.While fixing this I also found and fixed a separate latent bug in
copyExifInfo()that causes double-rotation once this decode path is used with a source that has a non-.upEXIF orientation. Details in the PR description.