Stop Using @capacitor/camera in Your Ionic Projects — Here's Why We Ditched It
When we first built our offline background removal app for Android, @capacitor/camera looked like the obvious helper: open the camera, pick from the gallery, and get a URI back in one line of code. For most use cases, the plugin works perfectly fine—handling typical selfies, screenshots, and standard gallery photos without any issues.
However, things started to break when we moved from internal QA to open testing. Users began selecting high-resolution images, and that's when Google Play Console's Android Vitals lit up with ANRs we could trace straight into the plugin. One of our users crashed his app by selecting a multi-hundred MB image. After investigating, we discovered that images around 1200×1200 pixels and larger will cause the @capacitor/camera plugin to freeze and eventually crash the app on mid-range devices.
How We Discovered It: Android Vitals Console
On our own devices—nothing crashed. Development was smooth, internal testing passed, and everything looked solid. Then we published to the open testing track and within 24 hours, the Android Vitals dashboard started showing critical ANR (Application Not Responding) rates. This was unexpected, to say the least.
The First Alert
Google Play Console flagged our app with:
⚠️ ANR rate above threshold (0.47%)
Issue: Input dispatching timed out (Waiting to send key event)
Affected devices: OnePlus Nord CE 2 Lite 5G, Samsung Galaxy A32, others
The complete stack trace from Android Vitals showed the problem clearly:
"main" tid=1 Native
#00 pc 0x0000000000367f08 /system/lib64/libhwui.so (neon::parametric+500)
#01 pc 0x000000000036d0fc /system/lib64/libhwui.so (neon::start_pipeline+144)
#02 pc 0x00000000003a00d4 /system/lib64/libhwui.so (SkRasterPipelineBlitter::blitRectWithTrace+760)
#03 pc 0x00000000003ca4a8 /system/lib64/libhwui.so (antifilldot8+276)
#04 pc 0x00000000003c9d84 /system/lib64/libhwui.so (SkScan::AntiFillRect+712)
#05 pc 0x00000000003231a4 /system/lib64/libhwui.so (SkDraw::drawRect const+1352)
#06 pc 0x0000000000324af0 /system/lib64/libhwui.so (SkDraw::drawBitmap const+1304)
#07 pc 0x00000000002e95e4 /system/lib64/libhwui.so (SkBitmapDevice::drawImageRect+540)
#08 pc 0x000000000030a158 /system/lib64/libhwui.so (SkCanvas::onDrawImageRect2+352)
#09 pc 0x000000000026e350 /system/lib64/libhwui.so (android::SkiaCanvas::drawBitmap+656)
at android.graphics.BaseCanvas.nDrawBitmap (Native method)
at android.graphics.BaseCanvas.drawBitmap (BaseCanvas.java:233)
at android.graphics.Canvas.drawBitmap (Canvas.java:1589)
at android.graphics.Bitmap.createBitmap (Bitmap.java:1010)
at com.capacitorjs.plugins.camera.ImageUtils.transform (ImageUtils.java:63)
at com.capacitorjs.plugins.camera.ImageUtils.correctOrientation (ImageUtils.java:80)
at com.capacitorjs.plugins.camera.CameraPlugin.prepareBitmap (CameraPlugin.java:744)
at com.capacitorjs.plugins.camera.CameraPlugin.returnResult (CameraPlugin.java:606)
at com.capacitorjs.plugins.camera.CameraPlugin.processPickedImage (CameraPlugin.java:468)
at com.capacitorjs.plugins.camera.CameraPlugin.lambda$openPhotos$4 (CameraPlugin.java:399)
The smoking gun: com.capacitorjs.plugins.camera.ImageUtils.transform was blocking the main thread inside the Capacitor Camera plugin—nowhere near our application code.
Reproducing Locally
We couldn't reproduce the issue on our development devices (which, admittedly, were higher-end phones with 8GB+ RAM). However, the Vitals console told a different story—the crashes were happening exclusively on mid-range devices:
Sample affected device attributes from Android Vitals:
- OnePlus Nord CE 2 Lite 5G (6GB RAM)
- Samsung Galaxy A32 (4GB RAM)
- Xiaomi Redmi Note 10 (4GB RAM)
To reproduce the issue locally, we tested on a Google Pixel 6a (6GB RAM—similar specs to the affected devices) and fed the picker a 12,288 × 16,320 px (~200 MB) image captured from a high-resolution camera.
Result: Within seconds, the UI froze completely. Touch input stopped responding, and Android's watchdog killed the app with an ANR. Logcat confirmed what we suspected:
E/Choreographer: Skipped 500+ frames! The application may be doing too much work on its main thread.
E/OpenGLRenderer: Davey! duration=5000ms; Flags=1, FrameTimelineVsyncId=85644
E/InputDispatcher: channel 'com.rembg.editor.app/com.rembg.editor.MainActivity (server)' ~ Channel is unrecoverably broken
Here's the catch: This happens when users select high-resolution photos—think 200 MB files, which are incredibly common nowadays with modern smartphone cameras. A single photo from an iPhone 14 Pro Max or Samsung Galaxy S23 Ultra can easily hit 12+ megapixels at full resolution. Therefore, what works perfectly fine on your development device with 12 GB of RAM will choke a mid-range phone with 4-6 GB when the user picks that massive vacation photo they just took.
So Why Does a "Simple" Camera Plugin Melt the UI?
The Root Cause: Main-Thread Image Decoding
When you call the convenience API:
const photo = await Camera.getPhoto({ quality: 90, resultType: CameraResultType.Uri, });
Capacitor's native pipeline still decodes and transforms the full-resolution bitmap on the main thread. Even if you pass width/height options, the plugin loads the original dimensions first and scales afterward. That means hundreds of megabytes of bitmap data blocking input dispatch before your code even runs. It works fine for typical selfies or screenshots, but throw a 24-megapixel DSLR photo at it and your app freezes for seconds.
The Fix: Build a Native Plugin
We removed @capacitor/camera altogether and shipped a tiny Kotlin plugin that does things differently. Here's the approach:
- First, it lets the user choose between gallery and camera
- Then, it reads image bounds with
inJustDecodeBounds=truewithout actually decoding the pixels—this is key - Next, it downscales huge sources on a background thread using
inSampleSize - After that, it compresses the result to JPEG inside the app cache
- Finally, it returns
{ path, webPath, mimeType, width, height }back to JavaScript
By the way, this entire pipeline runs off the main thread, so the UI stays responsive even when processing massive files.
Kotlin Plugin
@CapacitorPlugin(name = "NativeImagePicker") class NativeImagePicker : Plugin() { @PluginMethod fun pickImage(call: PluginCall) { val maxW = call.getInt("maxWidth") ?: 4000 val maxH = call.getInt("maxHeight") ?: 4000 val intent = Intent(Intent.ACTION_PICK).apply { type = "image/*" } startActivityForResult(call, intent, "pickImageResult") } override fun handleOnActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { if (resultCode != Activity.RESULT_OK || data?.data == null) return val uri = data.data!! CoroutineScope(Dispatchers.IO).launch { val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true } context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, opts) } val sample = calculateInSampleSize(opts.outWidth, opts.outHeight, 6000, 6000) val decodeOpts = BitmapFactory.Options().apply { inSampleSize = sample } val bmp = context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, decodeOpts) } val outFile = File(context.cacheDir, "preview_${System.currentTimeMillis()}.jpg") outFile.outputStream().use { stream -> bmp?.compress(Bitmap.CompressFormat.JPEG, 90, stream) } val result = JSObject().apply { put("path", outFile.absolutePath) put("webPath", Capacitor.convertFileSrc(outFile.absolutePath)) put("mimeType", "image/jpeg") put("width", bmp?.width) put("height", bmp?.height) } call.resolve(result) } } private fun calculateInSampleSize(w: Int, h: Int, maxW: Int, maxH: Int): Int { var sample = 1 if (h > maxH || w > maxW) { val halfH = h / 2 val halfW = w / 2 while ((halfH / sample) >= maxH && (halfW / sample) >= maxW) { sample *= 2 } } return sample } }
This pipeline never touches the UI thread during decode, even when somebody uploads a billboard-sized TIFF.
Web Fallback for Browser Builds
Capacitor plugins don't run on the web, so we mirrored the API with a minimal file-input wrapper. This is the secret to why our app is fully cross-platform—the same codebase powers our native Android app and our web app at app.rembg.com, all using the identical JavaScript API.
import { WebPlugin } from '@capacitor/core'; export class NativeImagePickerWeb extends WebPlugin { async pickImage() { return new Promise((resolve) => { const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*'; input.onchange = () => { const file = input.files?.[0]; if (!file) return; resolve({ path: null, webPath: URL.createObjectURL(file), mimeType: file.type, }); }; input.click(); }); } }
Same JS API, two implementations. One native (Kotlin) for Android, one web (HTML input) for browsers—both handle image selection flawlessly across all platforms.
Results After Migration
The difference was night and day:
- ✅ Zero ANRs reported in Play Console since the change
- ✅ No more "Davey!" or skipped-frame warnings in logcat
- ✅ Instant image selection even for 200 MB, 16K×12K uploads
- ✅ Predictable memory footprint and GC behavior
- ✅ Identical JS code paths for Android and web builds
We reran the 200 MB stress test, and the UI stayed buttery smooth. By the way, our Android Vitals score went from "needs improvement" to green across all metrics.
TL;DR
If your Ionic app ingests big images, @capacitor/camera is doing way too much work on the main thread. Here's what actually works:
- Read image bounds first (without decoding), then downscale in the background, and compress off the UI thread
- Return only lightweight metadata to JavaScript—not the full bitmap
- Keep a browser fallback so the API stays consistent across platforms
The bottom line: Capacitor's bridge lets you own the native pipeline. We ditched the stock camera plugin, and our ANRs disappeared overnight. Sometimes the "official" solution isn't the best solution.
