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