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.
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::reactIt 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);
}
@endThe 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):
- Native Modules: Introduction: writing a TurboModule spec and implementing it on Android and iOS.
- Cross-Platform Native Modules (C++): pure C++ TurboModules, including registering them on Android with
OnLoad.cppandcxxModuleProvider. - What is Codegen?: how specs become native interfaces.
- Advanced Topics on Native Modules
- Appendix: which spec types map to which native types.
The example code:
- turjoy-real/rn-legacy-cpp-turbomodule: the complete app from this article.
From the Android docs:
- Support 16 KB page sizes: the Google Play requirement, and how to rebuild native libraries with 16 KB alignment.