#50217·expo

[expo-image-manipulator][Android] Releasing a context while `renderAsync()` is pending cancels the render — `useImageManipulator` does exactly that on unmount

Author: dev-eyoungminCreated Sep 16, 2026Updated Sep 17, 2026
Labelsneeds reviewcontributor: external

Minimal reproducible example

https://github.com/dev-eyoungmin/expo-image-manipulator-release-race

Steps to reproduce

The repo is npx create-expo-app@latest --template blank-typescript (SDK 57) plus expo-image-manipulator and expo-asset, with no third-party packages. It runs three phases over a bundled 4000x3000 JPEG on mount and renders the failure count for each. The same lines appear in adb logcat -s ReactNativeJS, prefixed REPRO.

  1. git clone https://github.com/dev-eyoungmin/expo-image-manipulator-release-race && cd expo-image-manipulator-release-race
  2. npm install
  3. npx expo run:android with an emulator or device attached (development build, npm)
  4. Read the three cards on screen.
  5. For the platform comparison, run the same commit with npx expo run:ios.

Every phase runs 10 rounds against the same image with the same transformer pipeline (resize -> rotate x3 -> resize). The only difference between them is when the context is released.

Phase When release() happens Android iOS
A · control after the renderAsync() promise has resolved 0 / 10 failed 0 / 10 failed
B · explicit 20 ms after the call, while the promise is pending 10 / 10 failed 0 / 10 failed
C · useImageManipulator by the hook, when the component unmounts mid-render 10 / 10 failed 0 / 10 failed

Phase A also prints the measured render duration — ≈81 ms on the Android emulator, ≈579 ms on the iOS simulator — so you can confirm that the 20 ms delay in phases B and C lands while the render is genuinely still in flight.

Expected: a pending renderAsync() resolves with the rendered image, or rejects with a real image-processing error. Releasing the context means "JS is done with this object", not "throw away the result of the call I am still awaiting".

Actual (Android): the promise rejects.

Call to function 'Context.renderAsync' has been rejected.
→ Caused by: kotlinx.coroutines.JobCancellationException: DeferredCoroutine was cancelled; job=DeferredCoroutine{Cancelled}@...

Phase C is the one that matters in an app: nothing in that component calls release(). useImageManipulator is backed by useReleasingSharedObject, which releases the context in its unmount cleanup, so navigating away from a screen while its image is still rendering is enough.

Cause

ImageManipulatorContext.sharedObjectDidRelease() cancels the very Deferred that a pending renderAsync() call is awaiting — ImageManipulatorContext.kt:

override fun sharedObjectDidRelease() {
  task.cancel()
}

renderAsync awaits that job:

AsyncFunction("renderAsync") Coroutine { context: ImageManipulatorContext ->
  val image = context.render()
  ImageRef(image, runtime)
}

iOS has no equivalent: ImageManipulatorContext.swift cancels only in reset(), which is why the same JS resolves there.

Production impact (SDK 56)

We hit the garbage-collected variant of this in production after upgrading SDK 54 → 56: 20 events from 17 users in the first 20 hours, Android only, 0 occurrences in the preceding 90 days on SDK 54 with byte-identical app code, failing 169 ms and 378 ms after manipulate() on 4000x3000 camera JPEGs. That GC path is #49799, with a fix in #49807. This issue is the part that remains once the receiver is kept alive: an explicit release, including the one the hook performs.

Suggested fix

Cancel only work that no call is waiting for:

private val lock = Any()
private var pendingRenders = 0

suspend fun render(): Bitmap {
  synchronized(lock) { pendingRenders += 1 }
  try {
    return task.render()
  } finally {
    synchronized(lock) { pendingRenders -= 1 }
  }
}

override fun sharedObjectDidRelease() {
  if (synchronized(lock) { pendingRenders == 0 }) {
    task.cancel()
  }
}

This keeps the intent of the current code — an abandoned context stops doing work — while not cancelling a render someone is still awaiting, and brings Android in line with iOS. With this change the repro reports 0 / 10 in all three phases. I have a PR ready if it is wanted.

Note for whoever verifies a fix

expo-image-manipulator ships a prebuilt AAR (local-maven-repo/…/expo.modules.imagemanipulator-<version>.aar), so editing the Kotlin in node_modules alone has no effect. The repro repo sets

"expo": { "autolinking": { "buildFromSource": ["expo-image-manipulator"] } }

in package.json so the module is compiled from source.

Devices: Android emulator, Pixel 9 Pro Fold, API 36 (Google Play ARM64); iOS simulator, iPhone 17 Pro, iOS 26.5. Both debug builds, Hermes, New Architecture, expo-image-manipulator 57.0.18.

Environment

expo-env-info 2.1.0 environment info:
    System:
      OS: macOS 27.0
      Shell: 5.9 - /bin/zsh
    Binaries:
      Node: 22.19.0 - /usr/local/bin/node
      npm: 10.9.3 - /usr/local/bin/npm
      Watchman: 2025.09.01.00 - /opt/homebrew/bin/watchman
    Managers:
      CocoaPods: 1.16.2 - /opt/homebrew/bin/pod
    SDKs:
      iOS SDK:
        Platforms: DriverKit 27.0, iOS 27.0, macOS 27.0, tvOS 27.0, visionOS 27.0, watchOS 27.0
      Android SDK:
        API Levels: 35, 36
        Build Tools: 34.0.0, 35.0.0, 36.0.0, 36.1.0
        System Images: android-29 | Google APIs ARM 64 v8a, android-36 | Google Play ARM 64 v8a, android-37.0 | 16 KB Page Size Google Play ARM 64 v8a
    IDEs:
      Android Studio: 2025.2 AI-252.27397.103.2522.14617522
      Xcode: 27.0/27A266a - /usr/bin/xcodebuild
    npmPackages:
      expo: ~57.0.23 => 57.0.23 
      react: 19.2.3 => 19.2.3 
      react-native: 0.86.3 => 0.86.3 
    Expo Workflow: bare

Expo Doctor Diagnostics

Running 21 checks on your project...
21/21 checks passed. No issues detected!