In my eight years of professional software engineering, I have found that the true test of an architecture is not how it performs on a $1,200 flagship device, but how it holds up on a $100 Android handset. During my time as the founding engineer at Synapsis Medical Technologies, where I owned the React Native and Next.js architecture from 0 to 1, I faced a specific challenge: delivering real-time clinical AI insights on low-end mobile hardware without dropping frames.
When building a HealthTech AI platform, the user interface cannot afford to stutter. If a clinician is using a mobile device to capture diagnostic images or interact with a HIPAA-aligned RAG pipeline—which I scaled to 99.9% uptime—the feedback loop must be instantaneous. On budget hardware, the JavaScript thread is a precious commodity. If you task it with image decoding or machine learning (ML) preprocessing, the UI will freeze, the framerate will plummet to 15fps, and the application will become unusable.
The Bottleneck: The JavaScript Bridge and Main Thread Contention
The standard React Native architecture often falls into a trap where the JavaScript thread becomes the orchestrator for heavy data processing. On a budget Android device, the CPU is frequently a low-power ARM Cortex-A53 or A55. These chips have limited single-core performance.
When an image is captured or retrieved from a remote clinical database, it typically exists as a Base64 string or a local file URI. If you attempt to decode this image or resize it for an ML model—such as a TensorFlow Lite (TFLite) classifier—within the standard JS execution context, you trigger a cascade of performance failures:
- GC Pressure: Large byte arrays in JS trigger frequent Garbage Collection cycles.
- Bridge Congestion: Passing large amounts of image data across the React Native bridge creates a bottleneck that delays UI updates.
- Main Thread Blockage: On Android, the UI thread and the JS thread compete for cycles on the same limited cores.
To maintain a consistent 60fps, the JS thread must be reserved strictly for business logic and UI state management. Everything else—decoding, color space conversion, and tensor preparation—must move to the background.
Moving Image Decode Off-Thread
The first step in reclaiming the 60fps target is removing image decoding from the JS-to-Native bridge. In my experience shipping 18+ production applications, I have seen developers attempt to use standard <Image> components for high-frequency updates, only to see the "flicker" of the placeholder as the native side struggles to decode the buffer.
On budget Android devices, we bypass the bridge using JSI (JavaScript Interface). Instead of sending a Base64 string, we send a memory pointer or a file descriptor. We then utilize the Android BitmapFactory within a dedicated background thread pool.
By offloading the decode to Schedulers.io() or a custom ThreadPoolExecutor in Java/Kotlin, we ensure that the heavy lifting of turning compressed JPEG/PNG bytes into a raw pixel array does not touch the UI thread.
ML Preprocessing: The Hidden Performance Killer
In the HealthTech AI platform I built, we integrated wearables and clinical data into RAG/LLM pipelines. Often, this required local preprocessing of visual data before sending it to an inference engine.
ML models rarely take a standard camera output. They require specific dimensions (e.g., 224x224), specific color formats (RGB vs. BGR), and normalized float values (0.0 to 1.0). If you perform this normalization in JavaScript by iterating over a pixel array, you are effectively killing the application's performance. A 224x224 image has 50,176 pixels. Iterating through three color channels means 150,528 operations per frame. On a $100 phone, this takes well over 16ms, making 60fps mathematically impossible.
The solution is to move this logic into C++ using the NDK (Native Development Kit) and link it via JSI.
The Architecture: JSI and Native Buffers
By using JSI, we can expose C++ functions directly to JavaScript. This allows us to pass a SharedArrayBuffer or a reference to a native HardwareBuffer.
// Example of a Native Preprocessing Hook via JSI
jsi::Value preprocessImage(jsi::Runtime& rt, const jsi::Object& buffer) {
// 1. Get pointer to the raw image data
auto array = buffer.getPropertyAsObject(rt, "data").getArrayBuffer(rt);
uint8_t* pixelData = array.data(rt);
// 2. Perform resizing and normalization in C++ using SIMD instructions
// This happens off the JS thread in a background worker
dispatch_to_background([=]() {
fast_resize_and_normalize(pixelData, targetBuffer);
// 3. Trigger a callback when the tensor is ready for inference
});
return jsi::Value::undefined();
}
This approach allows the JS thread to remain idle while the CPU's secondary cores handle the mathematical transformations.
Architecture and Trade-offs
When I scaled the engineering team at Synapsis from 0 to 21 engineers in 13 months, one of the primary technical hurdles was balancing feature velocity with this level of low-level optimization.
The Trade-off: Development Speed vs. Performance
Writing JSI wrappers and C++ kernels is significantly slower than writing pure TypeScript. For 80% of applications, this is overkill. However, when your target demographic uses hardware with limited thermal headroom and weak GPUs, this native overhead is the only way to achieve a "premium" feel.
Memory Management
Moving data to the native side introduces the risk of memory leaks. Unlike the JavaScript environment, where the garbage collector handles cleanup, manual memory management of HardwareBuffers or DirectByteBuffers is required. We implemented a pooling strategy where we reused a set of pre-allocated buffers to prevent the overhead of frequent allocations, which is particularly expensive on low-end Android kernels.
Worked Example: Real-time Signal Processing
Consider a scenario where the app must process a 30fps camera feed for a clinical diagnostic tool.
- The Naive Way: Camera frame -> Base64 -> JS Bridge -> JS
map()for normalization -> Bridge -> TFLite.- Result: 8-12 fps, device overheating, UI freezes.
- The Optimized Way: Camera frame ->
ImageReader(Native) -> YUV to RGB conversion (RenderScript or Vulkan) -> Native Tensor Buffer -> TFLite.- Result: 60fps UI, 30fps inference, stable thermals.
By using ImageReader on the native side, we can access the image planes directly in memory. We then use a JSI reference to tell the JS thread that a new frame is "ready." The JS thread only needs to update a small piece of state to trigger a re-render of the overlay, while the heavy processing happens entirely in the background.
What it Cost to Learn
Building this infrastructure taught me that CI/CD is just as important for performance as the code itself. When I overhauled our CI/CD across 5 production systems to cut release cycles from 2 days to 4 hours, I integrated automated performance profiling.
We discovered that even a minor change in how the React tree was structured could cause "over-rendering" that, when combined with our native processing, would push the CPU over its limit. We learned that on budget hardware, you cannot treat the JS thread and the Native thread as independent; they share the same hardware limitations. If the native side uses 70% of the CPU, the JS thread only has 30% left to maintain the UI.
We had to implement a "frame budget" system. If the native processing took longer than 10ms, we would intentionally drop the inference frequency to 15fps while keeping the UI at 60fps. This ensured the user experience remained fluid even if the AI analysis lagged slightly behind.
Practical Recommendations
For engineers tasked with supporting low-end Android devices, I recommend the following:
- Avoid Base64: Never pass images across the bridge as strings. Use file URIs or, preferably, JSI-wrapped memory pointers.
- Use the NDK for Math: If you are doing anything more complex than a basic array sort, move it to C++. The performance delta on low-end ARM chips is massive.
- Profile on Real Hardware: Emulators are deceptive. A $100 phone has different thermal throttling profiles and disk I/O speeds than a high-end workstation.
- Offload Image Loading: Use libraries like Glide or Fresco on the native side, but configure them to use a custom, lower-priority thread pool so they don't starve the UI thread.
- Pre-allocate Tensors: Allocation is expensive. Pre-allocate your input and output buffers for ML models at startup.
Conclusion
Achieving 60fps on a $100 Android phone is not a matter of writing "better" JavaScript; it is a matter of writing less JavaScript. By moving image decoding and ML preprocessing into the native layer and utilizing JSI for low-latency communication, we can provide a high-end experience on budget hardware. In my work building HIPAA-aligned AI systems and scaling complex architectures, this separation of concerns has been the defining factor in delivering software that is both powerful and accessible.
Amit Chakraborty is a founding engineer and senior architect — React Native, AI/RAG systems and production architecture. Portfolio: www.amitchakraborty.dev · LinkedIn · GitHub. Open to senior and founding engineering roles, remote worldwide.
Top comments (0)