How a Creative Solution Kept Our Legacy C++ Image Processing Alive

One TurboModule spec, two native backends: a pure C++ module on Android and a native module on iOS, wired together with codegen to save years of on-device image processing work.

6 min read

Here's the whole story at a glance, before and after:


The C++ at the bottom never changed. Only the path to it did.

The problem

The app had on-device image processing modules written in C++.

They were built by engineers long back, and they worked. Both iOS and Android depended on them.

Rewriting them was not an option. That logic was tuned over years, and it ran on the device for a reason.

But the bridge into that C++ was breaking.


Why it was urgent

Android is moving to 16 KB memory pages.

Google Play requires every app targeting Android 15 (API 35) or higher to support 16 KB page sizes on 64-bit devices. From February 1, 2027, app updates that don't support them can't be released.

Apps with only Java or Kotlin code are fine. Apps that ship native .so libraries are not, until those libraries are rebuilt with 16 KB ELF alignment.

Our image processing was exactly that: C++ compiled into a native library. So it had to be rebuilt the right way, not just kept alive.

There was a second constraint. I didn't want Android to end up on one architecture and iOS on another. Two different integrations for the same C++ means two places to debug, and two ways for the platforms to drift apart.

So the fix had to do two things at once:

  • Add 16 KB support on Android by building the C++ through the app's own NDK and CMake setup.
  • Keep one consistent codebase across iOS and Android, behind one TurboModule spec.

What broke on each platform

Android

The C++ method used to be called from Java.

That Java entry point was no longer there. Without it, nothing on the JavaScript side could reach the C++ code.

iOS

iOS was in better shape. A Swift native module called into the C++ method, and that call still worked.

But the module still had to be integrated as a native module to be usable from React Native.

So each platform needed a different route to the same C++ logic.


The idea: one spec, two backends

TurboModules give you a typed spec in TypeScript. Codegen turns that spec into native interfaces.

The trick is that the spec does not care how it is implemented.

So I used the same spec for both platforms:

  • Android: a pure C++ TurboModule. JavaScript calls straight into C++ over JSI. No Java in the middle.
  • iOS: a native module that keeps the existing Swift → C++ call.

JavaScript sees one module with one name and one set of types. Each platform resolves it to a different implementation.


Step 1: The spec

A single spec file that both platforms implement.

// specs/NativeImageProcessor.ts
import type { TurboModule } from "react-native";
import { TurboModuleRegistry } from "react-native";
 
export interface Spec extends TurboModule {
  process(imagePath: string): string;
}
 
export default TurboModuleRegistry.getEnforcing<Spec>("NativeImageProcessor");

Step 2: Codegen

Codegen is pointed at the specs folder in package.json.

"codegenConfig": {
  "name": "AppSpecs",
  "type": "modules",
  "jsSrcsDir": "specs",
  "android": {
    "javaPackageName": "com.legacycppturbomodule.specs"
  },
  "ios": {
    "modulesProvider": {
      "NativeImageProcessor": "NativeImageProcessor"
    }
  }
}

From this one spec, codegen generates a C++ base class (used on Android) and an Objective-C protocol (used on iOS).

modulesProvider tells React Native which Objective-C class backs the module on iOS. Android needs no entry here, because the C++ module is registered by hand in the next step.

Step 3: Android, the C++ module

The C++ module extends the generated base class and calls the legacy code directly.

// shared/NativeImageProcessor.h
#pragma once
 
#include <AppSpecsJSI.h>
#include "LegacyImageProcessor.h"
 
namespace facebook::react {
 
class NativeImageProcessor
    : public NativeImageProcessorCxxSpec<NativeImageProcessor> {
 public:
  NativeImageProcessor(std::shared_ptr<CallInvoker> jsInvoker)
      : NativeImageProcessorCxxSpec(std::move(jsInvoker)) {}
 
  std::string process(jsi::Runtime& rt, std::string imagePath) {
    return legacy::processImage(imagePath);
  }
};
 
} // namespace facebook::react

It is registered in the app's OnLoad.cpp, so React Native can find it by name.

std::shared_ptr<TurboModule> cxxModuleProvider(
    const std::string& name,
    const std::shared_ptr<CallInvoker>& jsInvoker) {
  if (name == NativeImageProcessor::kModuleName) {
    return std::make_shared<NativeImageProcessor>(jsInvoker);
  }
  return autolinking_cxxModuleProvider(name, jsInvoker);
}

The C++ is compiled into the app's own native library through CMakeLists.txt.

# android/app/src/main/jni/CMakeLists.txt
target_sources(${CMAKE_PROJECT_NAME} PRIVATE
  ${SHARED_DIR}/legacy/LegacyImageProcessor.cpp
)

That's also where 16 KB support comes in. NDK r28 and later produce 16 KB-aligned libraries by default, so building the C++ with a current NDK is enough. React Native's template still pins an older NDK, so set it in android/build.gradle:

ndkVersion = "30.0.16248370"

The missing Java method no longer matters. Java is not in the path at all.

Step 4: iOS, the native module

On iOS the module conforms to the generated protocol and hands the call to the existing Swift code, which calls the C++ as before.

// NativeImageProcessor.mm
#import <AppSpecs/AppSpecs.h>
#import "LegacyCppTurboModule-Swift.h"
 
@interface NativeImageProcessor : NSObject <NativeImageProcessorSpec>
@end
 
@implementation NativeImageProcessor
 
+ (NSString *)moduleName {
  return @"NativeImageProcessor";
}
 
- (NSString *)process:(NSString *)imagePath {
  return [ImageProcessor process:imagePath]; // existing Swift → C++
}
 
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
    (const facebook::react::ObjCTurboModule::InitParams &)params {
  return std::make_shared<facebook::react::NativeImageProcessorSpecJSI>(params);
}
 
@end

The Swift and C++ code on iOS stays untouched. In the example, Swift reaches the C++ through a small C function exposed in the bridging header.

Step 5: Using it

From JavaScript, there is no platform check.

import NativeImageProcessor from "./specs/NativeImageProcessor";
 
const result = NativeImageProcessor.process(photo.path);

Why this mattered

  • The legacy C++ survived on both platforms. No rewrite, no port.
  • Android got 16 KB support. The C++ is now built by the app's own NDK toolchain, so it ships aligned for 16 KB pages.
  • One contract. The spec is the single source of truth, and codegen keeps both sides honest. If a type changes, both platforms fail to build until they match.
  • Each platform uses the shortest path. Android goes JS → C++ directly. iOS keeps a path that already worked.

The documentation treats C++ TurboModules and native modules as two separate choices.

They don't have to be. One spec can have a different backend on each platform, and that is what saved the on-device image processing that engineers built long back.


References

From the React Native docs (New Architecture):

The example code:

From the Android docs:

  • loading…

esc to close