TL;DR: Performance is crucial for Shopify. After we started using React Native, we had to find a way to confirm our mobile apps are fast. The solution is an open-source @shopify/react-native-performance
library by Shopify, which measures rendering times in React Native apps. In this article, you’ll learn more about how the library works, how to get started, and why measuring React Native performance is so important.
Before I joined Shopify, I primarily had experience with mobile native development. In the native world, everybody seemed to hate cross-platform: it’s too slow, it doesn’t feel "native”, it’s difficult to develop native features like video processing. (Read Shopify developers perspectives on native vs cross-platform in this article).
The general opinion was: If you want a quality app—do native development. Although it would require duplicating the code and hiring more people to develop two apps. React Native came to the rescue, solving all the problems: the resulting app not just feels, but actually is native in the end, and could be developed by one team with an incredibly popular technology—React. As a bonus, it could be developed not just for two platforms, but perhaps even for three, including web. Not surprisingly, big companies like Shopify, Microsoft and Coinbase followed Meta and started using React Native.
However, one concern has persisted in the community: is the resulting app indeed as fast as native? React Native uses a bridge to pass calls to native and back. Does rendering happen fast enough, so the user doesn't notice?
Performance is taken very seriously at Shopify. Higher latency anywhere in our system means less revenue for our merchants. Mobile is not an exception—it must be fast. Shopify already had a production React Native app and knew the pros and cons, which led to the decision to switch to native. However, having all the apps using React Native is different from proving the concept. So when Shopify went full steam on React Native, we had to ensure user experience and performance remained high as on native. And for that we needed numbers.
Native Shopify apps already had a successful experience monitoring performance, so we wanted to have something similar and familiar for developers in React Native. Both Native and Web used Apdex, an open standard solution that measures user satisfaction with response times for web applications and services. In the mobile's case, we consider the “application response time'' to be Time To Interactive (or TTI) for a particular flow or a screen. In other words, the moment a user can interact with an app.
The problem is, how do we get this TTI?
Meet @shopify/react-native-performance
: a library that enables React Native apps to measure their rendering metrics. In this article I’m going to walk you through how it works, how to get started with it, and how you can start using it to improve your app’s performance. Let’s dig in!
What the Library Measures
There are three kinds of render times you can measure using the library:
App startup render time
- The timer starts when the native portion of the app is started (Android's
onCreate
or iOS'sdidFinishLaunchingWithOptions
is called) - The timer stops when the app's first screen is mounted and fully rendered
Navigation render time
- The timer starts when some
Touchable
is pressed on screen A - The app navigates to screen B.
- The timer is stopped when the screen B is mounted and is fully rendered
Screen re-render time
- The timer starts when a particular UI event occurs on a screen (say pull-to-refresh)
- Stuff happens on the screen (e.g., network calls)
- The timer is stopped when the screen is fully rendered again
At this point, you might start wondering what is considered "rendered"? The thing is, React Native's render doesn't guarantee that the screen is already interactive. Our library injects an invisible marker view with a native counterpart that reports that the view actually did move to the window on the native side. It allows measuring actual end-to-end performance, including React Native to native bridge communication time. Therefore, this is what we consider TTI—the time a screen takes to get rendered on the native side and become interactive to the user.
Render Passes
Now that we know what "rendered" means, let's talk about “interactive”. Often the screen goes through a couple of steps before becoming interactive for a user: it may show a loading indicator, followed by rendering only the first half of the screen, followed by rendering the entire screen. For example, it may show a loading indicator, followed by rendering only the first half of the screen, followed by rendering the entire screen. The library models these incremental steps as different "render passes".
These render passes may be interactive
or not. An interactive
render pass implies that the user can interact with the screen after this (e.g., render from cached or network data); a non-interactive render pass means that the user cannot (e.g., loading screen or only partially rendered screen).
State Machine
The library models a screen's render pipeline and goes through multiple states mentioned earlier using a state machine. This state machine dictates the library's perception of the "world view", which in turn influences the API decisions.
Let’s look closer at the diagram:
- The state machine starts in the Started state—it can be an app start, a navigation button press, or a triggering pull to refresh.
- Mounted state corresponds to when the JS component was loaded.
- Rendered gets reported when native rendering is completed and leads to emitting a Render Pass Report.
- The screen can go through multiple rendered states until the screen gets into an Unmounted state. The library will also produce a
RenderPassReport
if the screen was unmounted before any render pass was completed with an interactive prop set to true. This acts as a user frustration signal that the user stopped waiting for the screen to become interactive and decided to back out.
Let’s look at the example of a resulting RenderPassReport generated by the library:
The most interesting thing for us here is time to render—the time the screen took to render before becoming interactive. This is the time that the user needs to wait for example between a navigation button press and the moment the next screen is interactive—the metric we want to optimize.
We can also see which screen triggered the transition in the sourceScreen
field and what was the next one in the destinationScreen
. flowInstanceId
helps us to keep track of the render passes following the same user flow. See more fields in the documentation.
Usage
Now that we’ve seen what the library reports, let's look into getting started with it and its API.
You can install the package by running the following command:
The library’s core functionality is a timer: the trick is to start and stop it at the right time. As I mentioned before, the library can measure three things: app startup time, navigation render time, and re-render time. We’ll see how to get each of them starting with app startup time.
We start integrating the library with initializing it in all three parts of your app: Android Native, iOS Native, and Typescript.
Android
Add this snippet to your Android MainApplication.java
. Make sure that the ReactNativePerformance.onAppStarted()
is the first line in the onCreate
method. We want to initialize the profiler as early as possible in the app's lifecycle to get the most accurate measurements.
iOS
Similarly, add this snippet in your iOS AppDelegate.m
. Again, ensure that the [ReactNativePerformance onAppStarted]
is the first line in the app startup routine.
These two calls start the timer on the native side that will be finished on the Typescript’s side.
Typescript
Now to Typescript. Mount the PerformanceProfiler
component somewhere high up in your App tree. For example:
At this point, the timer has already started. Now we need to stop it. To end the timer, you first need to identify the screens in your app that the user can arrive at on startup. These screens must be interactable for the user (i.e., probably don't want splash screens here). If your app has a "Home" screen, that’s probably it. Then, wrap the returned JSX of all of those screens' components with an PerformanceMeasureView
. For example:
The library will automatically recognize the very first PerformanceMeasureView
that gets rendered in the app, and consider it as the main landing screen on startup. It then waits for the native UI view of this screen to get rendered, and generates a RenderPassReport
as the output with timeToBootJsMillis
field—we got our first measurement: application startup time.
How to Measure Navigation Render Times
The timer for this use case starts when a Touchable on some ScreenA is pressed, and a navigate call to ScreenB is invoked. You can do so via the useStartProfiler
hook.
Just like before, you wrap the target ScreenB with an PerformanceMeasureView
to end the timer.
Packages for React Navigation
As you may have noticed, we need to make two calls to start the navigation process:
- Notify the library of the flow start via the
useStartProfiler
hook - Notify the navigation library of your choice that you're requesting a navigation to a given destination screen
If you use a react-navigation
library, we provide a simple wrapper API that can combine these two calls into a single call. Using this wrapper useProfiledNavigation
hook over the raw useNavigation
hook might help you ensure that all the navigation flows in your app have profiler coverage:
This API is available in a companion @shopify/react-native-performance-navigation package. This package also provides ReactNavigationPerformanceView
—a replacement for PerformanceMeasureView
to be used along with @react-navigation
. Its API is the same, however, it has a new render pass called transition-end
and influences when interactive is set to true. Whenever there is a navigation transition between screen A and screen B, ReactNavigationPerformanceView
ensures that screen B is not marked as interactive until the transition has been successfully completed. It ensures more precise measurements that include transition animation compilation reported by @react-navigation
.
The library is a part of a performance monorepo that contains other companion packages to the core one and also other related to performance topic:
Additional helper methods for @react-navigation/bottom-tabs library |
|
Additional helper methods for @react-navigation/drawer library. |
|
A Flipper plugin is available to make lists profiling easier. The plugin visualizes TTI, blank areas, and its averages in a convenient manner. |
What to Do With All This?
After integrating the library you might want to get your measurements on some external dashboard. It can be achieved easily by leveraging PerformanceProfiler
’s onReportPrepared
callback that gets called whenever a new event occurs and reports a json object that can be passed to a preferred analytics tool.
I’d like to show you how easy it is to report performance events to an analytics tool using Amplitude as an example. After integrating Amplitude React Native SDK, you only need one line to pass the event from onReportPrepared
callback to Amplitude:
Once you’ve got the events on your dashboard, you can set the monitoring to show how your screens perform with the time. There is also a possibility to add a breakdown of rendering steps and how long they took to make it easier to identify the bottlenecks.
Here is an example of an Amplitude dashboard that shows us the rendering time per screen by day. This dashboard uses events sent by a demo project that you can check out in react-native-performance-reporting-demo
repo. Using the app from this repo you can see the events appearing on the dashboard in real time!
I used the PERCENTILE(A, 0,99)
formula to get 0.99 percentile TTI per app screen by day. I don’t have a lot of events, therefore I used almost all of them, however with a larger amount of events you might consider a smaller percentile. Let’s take a look at the resulting graph.
The blue peak on November 18th is a TTI for “Performance” screen. It takes the longest to render, because it uses an artificial delay of 5 seconds to showcase how the screen can go through different rendering steps. Here we can also see other screens, compare them to each other to see clear deviations, and also monitor how TTI changes with time and to catch performance regressions
At Shopify, we have an internal dashboard with segmentation by screen name and calculated Apdex score for them out of the reported metrics. Thanks to that, teams can easily see which screens are underperforming and their trends.
Give Us Your Feedback
There is certainly a demand for more conversation about React Native performance. There were amazing performance-focused talks at the React Native EU and App.js conferences this year. Also at App.js, Reassure was introduced—a React performance testing library. Coinbase also has a performance monitoring system in the works called Performance Vitals.
The longing for improved performance is also evident in the popularity of FlashList—Shopify's performant replacement for FlatList. And we’re happy to see this trend since, together, we are pushing the technology to be faster for our users and deliver a better experience in the end.
Feel free to try out react-native-performance
library and leave your feedback!
Elvira Burchik is a Senior Production Engineer on the React Native Foundations team. Her mission is to create an environment in which developers are highly productive at creating high-quality React Native applications. She lives in Berlin, Germany.