React Native Performance Case Study, How It Differs From Native Apps: Part 1 (MessageQueue & JS Thread)

Avoiding congestion is key

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.


Good Citizens

“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

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 JSON-RPC, passing method calls from JavaScript to native, and callbacks from native back to JavaScript. This is the sole connection between the JavaScriptcontext and the native context. Everything is being passed through MessageQueue, every network request, network response, layout measurement, render request, user interaction, animation sequence instructions, invocation of native modules, I/O operation, you get the idea… Making sure MessageQueue isn’t congested is critical in order to ensure a smooth operation of our app.

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 MessageQueue.spy(true):

import MessageQueue from 'react-native/Libraries/BatchedBridge/MessageQueue'
MessageQueue.spy(true);
-or-
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 node_modules/react-native/Libraries/Utilities/MessageQueue.js

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 NetworkingModule:

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.

UI Freezes (ANRs in Android) will mostly not be triggered on RN apps

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.

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 SearchBar.android.js:
1. Spam mqt_js which runs a heavy calculation on the JS thread, causing it to be unable to do anything else for 8000ms.
2. 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 adb.

Setup
- enable spy mode logging (for release builds to emit spy logs, search-replace MessageQueue.js for __DEV__ and replace it with true

react-native run-android
adb logcat *:S ReactNative:V ReactNativeJS:V
  1. First, as a point of reference, this is how the app behaves normally.
Bridge and JS thread are idle, app behaves like we expect it to

2. Pressing 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.

3. Pressing Spam bridge and then trying to interact with the app:

Heavy load on the MessageQueue bridge cause delays on callback handling, and even JavaScript animations.

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)

  1. Our point of reference, nothing funky, normal behavior.
Point of reference

Relatively low CPU usage by mqt_js

2. Pressing Spam mqt_js

High CPU usage on mqt_js

as expected, high CPU usage on mqt_js

3. PressingSpam bridge - spamming the MessageQueue has a slightly different effect

Both ends are now busy

1. mqt_js is busy creating and sending messages to native
2. 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.

Closing Words

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