React Native (RN) allows web developers to write mobile applications that look and feel “native,” from the comfort of a JavaScript (JS) library, React. This ease-of-use has made React Native one of the most popular mobile frameworks, but it’s also the reason behind some inherent performance issues. In this blog post, we’ll explain the threading model in React Native and why that can affect performance, then introduce Product Science’s Tool as a way to debug these issues.
Introduction
React Native works using three main processes:
- JS Thread: Used for handling the logic of your React Native application
- React Native Modules Thread: Used when your app needs to access a platform API (e.g., if you’re working
with animations you may use the native driver to handle your animations) - UI/Main Thread: Handles rendering iOS and Android views
Notes:
- Each process is referred to as a “thread”, but that’s somewhat of a misnomer. They are actually each single-threaded processes.
- In this post, the “native” part of the RN app includes Java/Kotlin code and libraries executed in VM.
With React Native, code is executed on the JavaScript thread. When writing data to a disk, making a network request, or accessing any other native resources (like the camera), the code needs to call a native module. When rendering components with React, they will be forwarded to the UI manager native module, which will then perform layout computation and create the resulting views on the main thread.
An interaction between a JS thread and native modules is provided by a bridge. A bridge acts as an intermediary for communication, forwarding a call to the module and calling back to the code, if needed. In React Native, all calls to native modules have to be asynchronous to avoid blocking the main thread or the JS thread.
For example, let’s say that a user presses a button on the app:
The native thread would handle the onPress event by:
- Packing the payload to send over the bridge
- Sending the payload
The JS thread, meanwhile, would:
- Unpack the received payload
- Execute the bound code
Events and other data are passed this way between the JS thread and the native module, which has implications on performance. Even though calls between JS and native code are naturally low latency, when occupied by other tasks, threads aren’t able to respond to or act upon requests in a timely manner.
In most cases, business logic runs on the JavaScript thread. Updates to native-backed views are batched and sent over at the end of each iteration of the event loop before the frame deadline. But if the JavaScript thread is busy or unresponsive for a frame, the frame deadline (every ~16 ms) might be missed, leading to a dropped frame. For example, say setState() is called on the root component of a complicated application. This causes a re-render lasting 200 ms and results in several dropped frames. During this time, animations being controlled by the busy JavaScript thread may appear to freeze.
Additionally, since the native thread passes events back to the JavaScript thread, any lags on the JavaScript thread will have further effect. For example, a bottleneck in processing raw touch events received from the main/UI thread will incur a delay.
Product Science Technology
Because of the interaction between platforms and threads, React Native performance issues can be very subtle and difficult to debug. While many ad-hoc performance improvement tips exist, the full execution path of an application is not always accessible for developers. A comprehensive view of all threads and their relationships is needed to make the most impact, which is exactly what we’re building at Product Science.
Product Science is a self-service performance management platform for businesses with a mobile presence. Our flagship tool enables users to record mobile app traces of popular user flows, such as searching in chats (as seen in Figure 3), and analyze them together with real execution paths, empowering teams to identify potential optimizations.
Native Platform Tracing
The Android platform allows users to record traces and save them in a special protobuf format, unfortunately only covering essential system information, such as detail about frame draws and user events (Figure 3).
These system traces are not sufficient to debug React Native performance issues without information on application level classes and methods as well as, most importantly with React Native, the JS portion.
Product Science’s plugin for Android enriches traces with information on application level classes and methods, including all JVM code and libraries (Figure 5).
This provides the ability to observe all native code execution for React Native, including platform-specific application methods, native libraries, as well as much of the Android platform. One common example is a network request via the OkHttp library (Figure 6).
React Native Tracing
Out of the box, React Native does not provide any tracing tools, so the Product Science team developed a set of custom instrumentation which injects directly into the JS/TS code before compilation. These injections record essential information about application behavior, including full name and some arguments of called methods, as well as delay between the point in which a function is scheduled and when it’s actually called, and when some requests to the native part of the app are done.
For this JS/TS trace recording, Product Science uses a custom framework instead of relying on Android traces. This process does not influence app performance when sending tracing events to native platforms.
In doing so, the Product Science Tool records JavaScript traces simultaneously with enhanced Android system traces, later merging them to create a complete picture.
Example of Product Science's approach in a React Native app with a typical Search user flow (Figure 7):
This example shows how data from the Main Thread is passed into the JS Thread after the user clicks the Search button. After the click, the JS Thread processes data and triggers a network request via the OkHttp native library. When a response is obtained, it’s processed by the JS Thread and the result data is passed for rendering.
Technical Challenges
Merging the JavaScript and Android System traces required Product Science to overcome a number of technical challenges.
Aligning system time and JavaScript time
System time in Android is not the same as time in JavaScript/React Native - they each have their own origin and scale. To align them, Product Science’s React Native module emits a special event into the Android system trace containing both the current JavaScript timestamp, as well as the system timestamp.
Representing asynchronous calls for JavaScript
The JS thread executes all code asynchronously, which is why the team had to decide how to best represent async/await calls in JavaScript on the trace. Async/await calls are syntactic sugar and the code executed in a virtual machine like Hermes or V8 is actually quite different. Moreover, some asynchronous code blocks can be executed simultaneously without waiting for another block to be completed. So, functions had to be represented in a form that would be invariant under a desugaring process.
Now, visualizing asynchronous code blocks in one thread lacks a hierarchical structure of calls, meaning that the dependency between which function is calling another is unclear and visually shows an overlap of independent method executions. This results in the thread not being as representative as typical synchronous threads.
To remedy this, Product Science splits them up into synthetic threads to clearly visualize synchronously executed code blocks independently.
The end result is that unlike that of generic debugging tools, which bundle everything into only a few threads. The PS Tool breaks app flow down into hundreds of threads, giving users a more detailed view of execution in their applications, making it easier to diagnose issues.
Traces and mapping files versioning
To avoid negatively affecting performance of an app, PS instrumentation cites names of classes, methods, lambdas, etc. with a numeric ID reference during the build process.
During the merging of Android and JS traces it’s important to ensure both traces are from the same recording session, and the right mapping file is used. To do this, we use a similar approach to aligning time – emit a metadata event containing unique identifiers for the trace and build in both system trace and JS/TS trace.
Conclusion
React Native performance issues can be difficult to debug, because they’re often the result of interactions between the two sides of the framework – the JavaScript side and the native side. Product Science’s proprietary Tool can help, delivering unified and actionable insights about your code that will save seconds off core user flows.
If you’re interested in learning more about the execution path building process shown throughout this article, be on the lookout for an upcoming piece where we’ll explain this in detail for Android, JS/TS, and between the two.
NOTE: Since React Native 0.69, Turbo Native modules have been released. These modules introduce a JavaScript interface for native code (JSI), which allows for more efficient communication between native and JavaScript code than the bridge. Most libraries don't support this technology yet, but it can significantly improve RN performance. The Product Science team will investigate this further.
About the author: Gleb Morgachev is a software engineer with a background in data science. He’s a roundtable member of CodeMining at Open Data Science, placed third in the NLP2CMD competition at NeurIPS 2020, and has presented various published research and reports at multiple conferences worldwide.
Acknowledgements: This breakthrough has become possible due to Vitaly Khudobakhshov, David Liberman, Oleg Pashkovsky, Dmitry Melnikov, Jordan Wooten, Ilsur Gabdulkhakov, and others.
If you’re interested in tackling challenges like this, join our team! Time is humanity’s most valuable non-renewable resource. Our mission is to help all people in the world stop experiencing delays from software inefficiency.