Getting rid of frictions from your app as much as possible is important so that people can fully enjoy using it.
React Native is a framework that allows you to build a multi-platform mobile app quickly with JavaScript and React.
That helps me build the mobile version of my app called Inkdrop - A Markdown note-taking app that syncs across devices with end-to-end encryption.
Thanks to its architecture, I could build it so quickly and maintain it easily by reusing a lot of my codebase from the desktop version built with ElectronJS, which is awesome.
While React Native helped me a lot, I have been struggling with its lack of performance, especially in dealing with images. It took 40 seconds to download, decrypt, then display a 7MB image in the worst case (depends on network and device). The new version takes only several seconds.
I’m really happy with how it turned out.
Hi, it's Takuya.
In this article, I'd like to share what I've done to significantly improve my React Native app's performance.
TL;DR
- React Native is not as fast as NodeJS
- Do not use JavaScript-based polyfills if you need performance
- React Native can't handle binary string with NULL characters
- I replaced the polyfills with native modules
- Wrote JSI native modules in C++
- Should I adopt React Native for my new project? - Yes.
React Native is not as fast as NodeJS
Eh, performance? Yeah, React Native is basically performant.
But, React Native takes care of the rendering only.
Everything you know about React performance will apply in a React Native application.
React Native is also trying to improve launch speed by building their own JavaScript engine called Hermes.
So, the rendering is usually fine.
Do not use JavaScript-based polyfills if you need performance
However, RN has still a lack of binary support.
Unlike NodeJS, RN doesn't come with native modules for dealing with binary data out of the box, like crypto
and Buffer
.
When it comes to processing binary data, calculating a digest hash like SHA-1 and MD5 and converting from/to hex and base64 are popular tasks.
To accomplish those tasks, you have to use JavaScript libraries like spark-md5 and buffer.
If you need crypto
module, you have to install rn-nodeify and a bunch of polyfill libraries, which eventually messes up your project and makes it hard to maintain.
So, implementing the end-to-end encryption in React Native has been a big challenge for me.
Even worse, they are very slow because every polyfill is written in JavaScript.
After barely getting them to work on my project, I got a critical bug report from a user:
The app can't load images because encrypting/decrypting data on the client is too slow, which means that relying on JS-based libraries for dealing with binary data is a bad mistake.
So, I ended up implementing those tasks in native languages.
The NULL character annoyance
React Native allows you to make native modules so that you can use platform-specific APIs or reuse some existing libraries in Objective-C, Swift, Java, Kotlin, and C++.
Even with native code, it is still not that simple to deal with binary data because of this annoying issue:
That's because JSC always handles strings as UTF-8 terminating with NULL.
I don't know why there wasn't an issue in the old version of RN though.
This issue forces me to escape \0
characters to store blob data in my module called react-native-sqlite-2 like this:
function escapeBlob(data) {
if (typeof data === 'string') {
return data
.replace(/\u0002/g, '\u0002\u0002')
.replace(/\u0001/g, '\u0001\u0002')
.replace(/\u0000/g, '\u0001\u0001')
} else {
return data
}
}
This affects the performance to some degree, obviously, although it works fine in most cases.
But if you want to process binary data in native language, you have no choice to do like that or convert into base64.
Unfortunately, the community is not interested in solving it.
I replaced the polyfills with native modules
Instead of doing everything in JS, I wrote some native code (Java,Kotlin,Objective-C,Swift,and C++) to improve the performance. The following figure depicts how the app processes image data and pass it to a WebView:
As you can see above, the JS app just invokes native functions. Decrypting data is performed in my native module called react-native-aes-gcm-crypto, written in Kotlin and Swift.
Surprisingly, I confirmed that my crypto module is 50x faster than rn-nodeify
and react-native-crypto
on my iPhone 11 Pro (Watch my vlog):
It works pretty well.
Inter-communication between native modules
Since my app is a Markdown note-taking app, images have to be rendered in a WebView. I wrote an app-specific native code in Java and Objective-C to read an image file and pass it to the WebView directly because passing a base64-encoded image data from JS to WebView through the React Native bridge is redundant and slow.
To accomplish that, I dug into the RN core modules written in Java and Objective-C, and found how to get the existing RN view instance and control it from another native module via RCTBridge
on iOS:
#import <React/RCTUIManager.h>
RCT_EXPORT_METHOD(runJS:(NSString* __nonnull)js
inView:(NSNumber* __nonnull)reactTag
withResolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject) {
RCTUnsafeExecuteOnMainQueueSync(^{
RCTUIManager* uiManager = [self.bridge moduleForClass:[RCTUIManager class]];
RNCWebView* webView = (RNCWebView*)[uiManager viewForReactTag:reactTag];
if (webView) {
[webView injectJavaScript:js];
}
resolve(@"OK");
});
}
and ReactContext
on Android, like so
@ReactMethod
public void injectJavaScript(int reactTag, String js) {
UIManagerModule uiManagerModule = this.reactContext.getNativeModule(UIManagerModule.class);
WebView webView = (WebView) uiManagerModule.resolveView(reactTag);
webView.post(new Runnable() {
@Override
public void run() {
webView.evaluateJavascript(js, null);
}
});
}
A view instance identifier called reactTag
can be obtained via React's Ref:
import { WebView } from 'react-native-webview'
const YourComponent = (props) => {
const webViewRef = useRef()
useEffect(() => {
const { current: webView } = webViewRef
if (webView) {
console.log(webView.webViewRef.current._nativeTag)
}
}, [])
return (
<WebView
ref={webViewRef}
...
/>
)
}
Read my post for more detail.
By loading directly from the filesystem to WebView without the RN bridge, the app can show an image instantly.
Wrote JSI modules in C++
To make my app even faster, I created native modules for encoding/decoding base64 and calculating md5. They are implemented using JSI (JavaScript Interface), which is a new translation layer between the JS code and the native code in React Native. By using JSI, JavaScript can hold reference to C++ Host Objects and invoke methods on them. Which means that you can finally avoid the NULL character issue that I mentioned earlier. You can deal with binary data without escaping NULL characters by passing data as ArrayBuffer
like so:
#include <iostream>
#include <sstream>
using namespace facebook;
// Returns false if the passed value is not a string or an ArrayBuffer.
bool valueToString(jsi::Runtime& runtime, const jsi::Value& value, std::string* str) {
if (value.isString()) {
*str = value.asString(runtime).utf8(runtime);
return true;
}
if (value.isObject()) {
auto obj = value.asObject(runtime);
if (!obj.isArrayBuffer(runtime)) {
return false;
}
auto buf = obj.getArrayBuffer(runtime);
*str = std::string((char*)buf.data(runtime), buf.size(runtime));
return true;
}
return false;
}
You can also make an ArrayBuffer
object in C++:
std::string str = "foo";
jsi::Function arrayBufferCtor = runtime.global().getPropertyAsFunction(runtime, "ArrayBuffer");
jsi::Object o = arrayBufferCtor.callAsConstructor(runtime, (int)str.length()).getObject(runtime);
jsi::ArrayBuffer buf = o.getArrayBuffer(runtime);
memcpy(buf.data(runtime), str.c_str(), str.size());
return o;
It was hard to learn how to use JSI because there is no official comprehensive documentation and there are quite a few native modules using JSI yet.
You can check my JSI native modules as examples:
-
react-native-quick-md5
- 10x faster on iOS and 8x faster on Android
-
react-native-quick-base64
- 4x faster on iOS
Should I adopt React Native for my new project? - Yes.
Well, for attaining good performance in my project, I ended up writing Java, Kotlin, Objective-C, Swift, and even C++, which is kind of overwhelming.
I guess that not everyone wants to do so. It was so hard, obviously.
So, if your app is going to need intensively dealing with binary data, I'd recommend that you consider implementing it in native languages from the beginning.
But in most cases, I think that RN is just fine for building a good-quality app.
Because being able to build an app quickly is the key to bootstrapping a business, and I think that's the main benefit of React Native.
DHH said in his book:
It’s a Problem When It’s a Problem
Create a great app and then worry about what to do once it’s wildly successful.-- DHH, "Getting Real"
I've successfully built a note-taking app that runs on 5 platforms with Electron and React Native, because they allowed me to reuse a large amount of my JS codebase between mobile and desktop apps.
You should focus on making an app that scratches an itch instead of worrying about problems that aren't apparent to your project yet.
The React Native community is not focusing on providing solid APIs for binary data, unlike NodeJS. They are working on improving it as a front-end framework instead. And they would rely on extensions for other things like platform-dependent features.
There is a project called nodejs-mobile which allows you to integrate NodeJS into your app. While it'd be fun for hobby projects, it's scary to rely on such a minor framework for my app focusing on its longevity. In fact, this library looks inactive these days.
To put it in a nutshell, React Native is great for new projects, but not enough for fast data processing. You will eventually need to write native code.
I hope that helps!
- Follow me on Twitter & Instagram
- Solo developer's Discord community