Performance Limitations of React Native and How to Overcome Them
React Native under the hood
In order to understand the performance limitations of React Native, we must first get a glimpse into its inner workings. For the sake of clarity, I’ll try to keep this part high-level. If you’re looking for the gory details, see this excellent post by Tadeu Zagallo.
This implies that our app is running across two different realms:
- The native realm — The kingdom of Objective-C/Swift in iOS and Java in Android. This is where we interface with the OS and where all Views are rendered. UI is manipulated exclusively on the main thread, but there can be others for background computation. React Native does most of the heavy lifting in this realm for us.
Variables defined in one realm cannot be directly accessed in the other. This means that all communication between the two realms must be done explicitly over a bridge. This is in fact similar in concept to how clients and servers communicate over the web — data must be serialized in order to pass through. Cool anecdote —when you debug your RN JS code in Chrome, the two realms actually run on different computers (your desktop and your mobile) and the bridge between them passes over a WebSocket.
Here lies one of the main keys to understanding React Native performance. Each realm by itself is blazingly fast. The performance bottleneck often occurs when we move from one realm to the other. In order to architect performant React Native apps, we must keep passes over the bridge to a minimum.
React, with its concept of virtual-DOM, provides us with an excellent optimization out-of-the-box. Changes to our rendered components in JS are batched asynchronously with a smart diff algorithm — thus minimizing the amount of data sent over the bridge. This is actually the reason why React Native is more performant than competing technologies like Appcelerator that predated it by several years.
Playing with a real life use-case
See the swipeable card on the left? It’s a popular mobile UX pattern used in apps like Google Now.
It’s also surprisingly interesting to implement, performance-wise.
We’re going to implement this example multiple times and see the performance implications of each approach.
It makes sense to create a reusable component Swipeable that adds the swipe behavior (x translation and opacity change) to any content component we give it as child — Card in this case.
Our first implementation — PanResponder
Let’s start with the straightforward approach. Since we want to listen on touch gestures, we’ll use React Native’s PanResponder. Every time we receive a move event, we’ll calculate the new opacity and x translation based on the total horizontal distance traveled, and update them using local state:
What performance should we expect from this approach? Let’s remember the guideline stated earlier — In order to architect performant React Native apps, we must keep passes over the bridge to a minimum.
It seems that this example implementation is doing the exact opposite. Touch events originate in the native realm, since that’s where the device tracks the user’s finger. Our updates to the component’s state obviously happen in the JS realm. This is not normally a major issue, the problem here is that these updates take place on every frame! This means that for every single animation frame, where we want things to feel most fluid, data must pass over the bridge.
This is a performance bottleneck that pure native apps don’t have, making it much easier for them to reach the holy grail of 60 FPS, especially on weaker devices, and especially in real life cases that are a little more complicated than this example.
Didn’t I read something in the docs about Direct Manipulation?
If you care about performance, you’ve probably read the docs cover-to-cover and vaguely remember this article about direct manipulation of components.
Sounds promising, let’s update the native component directly and improve performance! We’ll give it a try, here is the implementation:
Did this solve our bridge performance issues? Not really — since we’re still updating from the JS realm. But it did optimize something worth understanding. In the previous implementation, on every frame we didn’t just send data over the bridge, we also re-rendered our component. In this specific case, the render function barely does anything so this wasn’t an issue. But what if our render function was more complex and computationally-intensive?
Traditionally, in order to update a React component, we have to re-render. If our update is very localized, like changing a specific style (x translation and opacity) we can surgically make it directly without the full render and reconciliation. This goes against the React “state of mind” so it’s best not to do this often and limit ourselves to use-cases where we have a specific property changing very rapidly (eg. during an animation).
Can we get back to fixing the bridge issue?
One of the most beautiful things about React Native is that we can take any piece of our codebase and move it seamlessly to native — even just a single component.
Developers often mistake React Native as a pure JS environment — it isn’t. It is true that JS would often give the best developer experience, but there are cases where native gives a superior user experience. I urge you, if you come from a web background — don’t fear native. It’s another tool in your belt which usually takes the same amount of stackoverflowing to exercise.
Since touch events originate in the native realm, what would happen if we do our x translation and opacity updates in native as well? Take a look:
The only part we’ve moved to native is the Swipeable container component. This would guarantee ourselves 60 FPS and it seems that the code is actually shorter. Notice that our Card content components remained in pure-JS. Here is how our native class is used inside our JS layout:
The future of React Native
While it is true that we can use native code selectively to plug our performance holes, the future of the framework is to improve and make sure we need to do so less and less.
It is possible to design clever JS interfaces that would minimize passes over the bridge and reach the same results. What if in our example, our JS code didn’t have to update the native realm on every frame? What if we could just specify once, in the beginning of the interaction, which properties are locked to which native event, and let some native module in the inner belly of React Native offload the updates for us? This would make us pass over the bridge just once — in the beginning.
React Native is evolving in this direction, and one of the primary treats we’ve been given is the new Animated library. Let’s implement our example for the fourth and last time with Animated in pure JS:
As you can see, the Animated library treats animations and interactions in a very declarative way. If we can declare how an interaction behaves, this declaration can be serialized and sent itself over the bridge. This opens the possibility for a generic native module to process the interaction for us and offload the frame by frame updates.
Unfortunately, the current (June 2016) implementation of Animated doesn’t offload everything to native yet. This means that our fourth implementation currently still suffers from the same bridge bottleneck. Having said that, progress is being made and I’m confident that future versions will allow us to overcome the bridge limitation from JS in many cases.
Comparing all four implementations
Reading about performance isn’t the same as feeling it in real life. You can play with fully working versions of the four implementations in the following repo, presented side-by-side for easy comparison:
Various performance experiments with React Native over a swipeable card examplegithub.com
Please run the example on an actual device since the simulator doesn’t give authentic results. In addition, the repo contains optional flags to simulate stress conditions in the app — such as bursts of activity over the bridge and computationally-heavier render functions. It’s interesting to examine how the fluidity of each implementation changes under these conditions.
Conclusion and parting words
Developing mobile apps in React Native is awesome, but convenience sometimes comes at a price. It is possible though to mitigate almost every performance issue, and the key is understanding what goes on under the hood.
At Wix.com, we are obsessive about UX and delivering the native user experience mobile users have come to expect. Here’s our rough guideline for obsessive React Native performance:
- Start by implementing everything in JS for maximum productivity. Don’t over-optimize too early.
- In areas that are prone for bridge-overuse, such as animations / interactions — prefer declarative libraries like Animated. Many interactions can be expressed declaratively and even-though it may not be intuitive at first (see our fourth implementation), it’s worth the effort.
- Wait for the full product on a real device to see where your app falters.
- If traditional React optimizations fail, surgically move the troublesome parts to native. We maintain a ratio of about 10% native developers in our engineering teams for this purpose.
- Encourage JS developers to dabble in native. It’s a powerful tool to use in the correct place and isn’t beyond reach. Some complex interactions cannot be expressed declaratively and offloaded by libraries like Animated.