React Native Performance Case Study, How It Differs From Native Apps: Part 1 (MessageQueue & JS Thread)
React Native is a hybrid framework. To run on a device it depends on two (or even three) different runtimes simultaneously. The additional abstraction layer benefits productivity and cut development time, but it comes with a cost. When things go wrong, this added abstraction makes it much harder to understand the system state, and debugging much more complex.
“Being a good citizen” is term often used by the Android framework team. As a developer, being dismissive about the amount of resources being used by your application is probably not a good idea.
As the memory on a device is limited, the bigger the heap grows, the more aggressive the system gets in freeing up space for the foreground app, triggering garbage collection, killing other apps, and causing overall performance degradation of the device.
Every usage of CPU, network and I/O, is less energy stored in the battery. being that good citizen means that you, the developer, should know and understand how your application performs, not just in terms of UI smoothness, that’s just a tiny part of the picture.
React Native is a hybrid creature, running on two realms on iOS (JScore virtual machine for JS, and native runtime for ObjC/swift) and three realms on Android (JSCore, Android runtime for java, and native runtime for C/C++). Each of these realms has its own isolated memory address space, on a different-ish heap. There is no single tool (yet) that understands all of them together, in order to get the whole picture we need to use multiple tools.
This post is the first in a, hopefully, series of posts, dedicated to familiarizing with how React Native works, how it performs on certain conditions, and tools that can help a developer detecting issues.
MessageQueue.js, the final frontier between JS and native, this is where all the communication between the two is being handled. Messages look a bit like
MessageQueue has a spy feature, enabling it will emit logs of all the messages passing between JS and Native contexts. To turn it on while running a debug build, set
import MessageQueue from 'react-native/Libraries/BatchedBridge/MessageQueue'
MessageQueue.spy((info) => console.log("I'm spying!", info));
On older versions of React Native, (older than 0.33), you’ll need to set a flag in the actual source in
const SPY_MODE = true;
This is how an output looks:
1. These are layout instructions sent from JS to RN Native module called
UIManager, A Native module to allow JS to create and update native Views:
2. Network request and response log messages, handled by
3. A single touch event:
This actually has a big impact on how the app performs when under stress
This communication between JS and native is asynchronous, and the queue handles messages as they arrive. Assuming that the JS thread is now busy, messages sent from the native context (touch events, network responses, etc.), may be handled by the JS thread with big delays.
It is very rare to encounter UI Freezes or StrictMode violations in an app written with react-native, the framework does a pretty good job at offloading almost anything of the main thread, and since most of the business logic is executed in
mqt_js (the JS thread) we may encounter a different kind of sluggishness than what we’ve seen on purely native apps. Instead of being non responsive (and may cause the system to trigger an ANR), the app will feedback to touches, and all views which have been already rendered will be perfectly responsive, but
onPress callbacks will not be processed. There are two main reasons for that…
For the purpose of this demonstration I forked the original Movies App Example, let’s have look.
react-native-sample-app-movies - A standalone port of react-native/SampleAppMovies, for educational purposesgithub.com
Note: debug builds of a react native application work entirely different from release builds, loading the JS bundle from the development machine via websocket (which actually leaks native threads and lots of memory :/), so for the sake of the demo, I will only use release builds.
react-native run-android —-variant release
I added two buttons in
Spam mqt_js which runs a heavy calculation on the JS thread, causing it to be unable to do anything else for 8000ms.
Spam bridge calls
setTimeout (JS) which is being translated to an invocation of
Timing.createTimer() (Native). This is being triggered multiple times in order to create very high traffic over the bridge.
These screen recordings show the app running in an android emulator, and MessageQueue spy logs via
- enable spy mode logging (for release builds to emit spy logs, search-replace MessageQueue.js for
__DEV__ and replace it with
adb logcat *:S ReactNative:V ReactNativeJS:V
- First, as a point of reference, this is how the app behaves normally.
Spam mqt_js and then trying to interact with the app:
Running heavy calculations on the JS thread cause delays in callback handling, no messages are being passed through MessageQueue since the JS thread is too busy doing other stuff.
Spam bridge and then trying to interact with the app:
Let’s see how these situations affect the CPU
but first, some basic CPU usage terms on Linux:
utime - CPU time spent in user code, measured in clock ticks.
stime - CPU time spent in kernel code, measured in clock ticks.
From Android Studio open Android Device Monitor (press cmd+shift+a (ctrl+shift+a for win/linux) and search for it). When you’re ready for measurement, click the Update Threads button (in the red box)
- Our point of reference, nothing funky, normal behavior.
Relatively low CPU usage by
as expected, high CPU usage on
Spam bridge - spamming the MessageQueue has a slightly different effect
mqt_js is busy creating and sending messages to native
mqt_native_moduels this thread runs the java invocations of all the native modules, and it is busy following the instructions and invoking createTimer() method in Timing.java.
Excessive traffic over the bridge puts both JS and Native threads in overdrive.
Being aware of the traffic handled by the MessageQueue is critical for the performance of your app. This is the first place I would go to when trying to debug issues, from network behavior to overall responsiveness, the
spy function is very powerful tool!
You can test all of this by yourself on the demo app mentioned above.
EDIT: You can find my talk about this post and more on Wix Engineering Tech Talks