Optimizing React Native
Published on: 19:11:2020.
by Nick Cherry, Staff Software Engineer
Over the past eight months, Coinbase has been rewriting its Android app from scratch with React Native. Read about some of the performance challenges we encountered and overcame along the way.
If you’re interested in technical challenges like this, please check out our open roles and apply for a position.
Over the past eight months, Coinbase has been rewriting its Android app from scratch using React Native. As of last week, the new and redesigned app has been rolled out to 100% of users. We’re proud of what our small team has been able to accomplish in a short amount of time, and we continue to be very optimistic about React Native as a technology, expecting it to pay continued dividends with regard to both engineering velocity and product quality.
That being said, it hasn’t all been easy. One area where we’ve faced notable challenges has been performance, particularly on Android devices. Over the next few months, we plan to publish a series of blog posts documenting various issues we’ve run into and how we’ve mitigated them. Today we’ll be focusing on the one that affected us the most: unnecessary renders.
Things Were Great Until They Weren’t
Early in the project, the app’s performance felt fine. It was nearly indistinguishable from a fully native product, even though we hadn’t spent any time optimizing our code. We were aware of other teams facing (and overcoming) performance challenges with React Native, but none of our preliminary benchmarks gave us reason to be alarmed. After all, the app we were planning to build was mostly read-only, didn’t need to display any massive lists, and didn’t require animations that couldn’t be offloaded to the native driver.
However, as more features were added, we started noticing a decline in performance. At first the degradations were subtle. For example, even with our production build, navigating to new screens could feel sluggish and UI updates would be slightly delayed. But soon it was taking over a second to switch between tabs, and after landing on a new screen, the UI might become unresponsive for a long period of time. The user experience had deteriorated to a point that was launch-blocking.
Identifying the Problem
To get a more holistic view of where re-rendering was most costly, we wrote a custom Babel plugin that wrapped every JSX element in the app with a Profiler. Each Profiler was assigned an onRender function that reported to a context provider at the top of the React tree. This top-level context provider would aggregate render counts and durations?—?grouping by component type?—?then log the worst offenders every few seconds. Below is a screenshot of output from our initial implementation:
As we observed in our previous benchmarks, the average render times for most of our atomic/molecular components were adequate. For example, our PortfolioListCell component took about 2ms to render. But when there are 11 instances of PortfolioListCell and each renders 17 times, those 2ms renders add up. Our problem wasn’t that individual components were that slow, it was that we were re-rendering everything far too much.
We Did This To Ourselves
To explain why this was happening, we need to take a step back and talk about our stack. The app relies heavily on a data-fetching library called rest-hooks, which the Coinbase Web team has been happily using for over a year now. Adopting rest-hooks allowed us to share a significant amount of our data layer code with Web, including auto-generated types for API endpoints. One notable characteristic of the library is that it uses a global context to store its cache. One notable characteristic of context, as described by the React docs, is that:
All consumers that are descendants of a Provider will re-render whenever the Provider’s value prop changes.
For us, this meant that any time data was written to the cache (e.g. when the app receives an API response), every component accessing the store would re-render, regardless of whether the component was memoized or referencing the changed data. Exacerbating the re-rendering was the fact that we embraced a pattern of co-locating data hooks with components. For example, we frequently made use of data-consuming hooks like useLocale() and useNativeCurrency() within lower-level components that formatted information according to the user’s preferences. This was great for developer experience, but it also meant that every component using these hooks?—?directly or indirectly?—?would re-render on writes to the cache, even if they were memoized.
Another part of our stack worth mentioning here is react-navigation, which is currently the most widely used navigation solution in the React Native ecosystem. Engineers coming from a web background might be surprised to learn that its default behavior is for every screen in the Navigator to stay mounted, even if the user isn’t actively viewing it. This allows unfocused screens to retain their local state and scroll position for “free”. It’s also practical in the mobile context, where we commonly want to show multiple screens to the user during transitions, e.g. when pushing onto / popping from stacks. Unfortunately for us, this also means that our already-problematic re-rendering could become exponentially worse as the user navigates through the app. For example, if we have four tab stacks and the user has navigated one screen deep on each stack, we would be re-rendering the greater part of eight screens every time an API response came back!
Once we understood the root cause of our most pressing performance issues, we needed to figure out how to fix it. Our first line of defense against re-rendering was aggressive memoization. As we mentioned earlier, when a component consumes a context, it will re-render when that context’s value changes, regardless of whether the component is memoized. This led us to adopt a functional container pattern, where we would hoist data-consuming hooks to a thin wrapper component, then pass the return values of those hooks down to presentational components that could benefit from memoization. Consider the gist below. Whenever the useWatchList() hook triggers a re-render (i.e. any time the data store is updated), we also need to re-render our Card and AssetSummaryCell components, even if the value of watchList didn’t change.
When applying the container pattern, we move the useWatchList() call to its own component, then memoize the presentational part of our view. We’ll still re-render WatchListContainer every time the data store updates, but this will be comparatively cheap because the component does so little.
The container pattern was a good start, but there were a few pitfalls we needed to be careful to avoid. Take a look at the example below:
It may appear that we’re protecting the memoized Asset from data-related re-renders by hoisting both useAsset(assetId) and useWatchListToggler() to a container component. However, the memoization will never actually work, because we’re passing an unstable value for toggleWatchList. In other words, every time AssetContainer re-renders, toggleWatchList will be a new anonymous function. When memo performs a shallow equality comparison between the previous props and the current props, the values will never be equal and Asset will always re-render.
In order to get any benefit from memoizing Asset, we need to stabilize our toggleWatchList function using useCallback. With the updated code below, Asset will only re-render if asset actually changes:
Callbacks aren’t the only way we can inadvertently break memoization, though. The same principles apply to objects as well. Consider another example:
With the above code, even if the Search component was memoized, it would always re-render when PricesSearch renders. This happens because spacing and icon will be different objects with every render.
To fix this, we’ll rely on useMemo to memoize our icon element. Remember, each JSX tag compiles to a React.createElement invocation, which returns a new object every time it’s called. We need to memoize that object to maintain referential integrity across renders. Since spacing is truly constant, we can simply define the value outside of our functional component to stabilize it.
After the following changes, our Search component can effectively be memoized:
Short-Circuiting Renders on Unfocused Screens
Memoization significantly reduced render counts / durations for each screen. However, because react-navigation keeps unfocused screens mounted, we were still wasting valuable resources re-rendering a great deal of content that wasn’t visible to the user. This led us to start digging through react-navigation’s documentation in search of an option that might alleviate this problem. We were hopeful when we discovered unmountOnBlur. Toggling the flag to true did reduce our renders considerably, but it only applied to unfocused tabs’ screens, keeping all of the current tab stack’s screen mounted. More damningly, it resulted in a flicker when switching between tabs and would lose the screen’s scroll position and local state when the user navigated away.
Our second attempt involved putting screens into suspense (falling back to a fullscreen loading spinner) by throwing a promise when the user navigated away, then resolving the promise when the user returned, allowing the screen to be presented again. With this approach, we could eliminate unnecessary renders and retain local state for all unfocused screens. Unfortunately, the experience was awkward because users would briefly see a loading indicator when returning to an already visited screen. Furthermore, without some gnarly hacks, their scroll position would be lost.
Eventually we landed on a generalized solution that prevented re-rendering on all unfocused screens without any negative side effects. We achieve this by wrapping each screen in a component that overrides the specified context (rest-hooks’ StateContext in this case) with a “frozen” value when the screen is unfocused. Because this frozen value (which is consumed by all components/hooks within the child screen) remains stable even when the “real” context updates, we can short-circuit all renders relating to the given context. When the user returns to a screen, the frozen value is nullified and the real context value gets passed through, triggering an initial re-render to synchronize all the subscribed components. While the screen is focused, it will receive all context updates as it normally would. The gist below shows how we accomplish this with DeactivateContextOnBlur:
And here is a demonstration of how DeactivateContextOnBlur can be used:
Reducing Network Requests
In the spirit of delivering value quickly, we opted for the low-cost solution of adding two new endpoints?—?one to return watchlist assets for the Home screen and another to return correlated assets for the Asset screen. Now that we were embedding all the data relevant to these UI components in a single response, it was no longer necessary to perform an additional request for each asset in the list. This change noticeably improved the TTI and frame rate for both relevant screens.
While the ad hoc endpoints benefited two of our most important screens, there are still several areas in the app that suffer from inefficient data access patterns. Our team is currently exploring more foundational solutions that can solve the problem generally, allowing our app to retrieve the information it needs with far fewer API requests.
With all the changes described in this post, we were able to reduce our render count and total time spent rendering over 90% (as measured by our custom Babel plugin) before releasing the app. We also see far fewer dropped frames, as observed via the React performance monitor. One key takeaway from this work is that building a performant React Native app is in many ways the same as building a performant React web app. Given the comparatively limited power of mobile devices and the fact that native mobile apps often need to do more (e.g. maintain a complex navigation state that keeps multiple screens in memory), following performance best practices is critical to building a high-quality app. We’ve come a long way in the past few months, but still have plenty of work ahead of us.
This website contains links to third-party websites or other content for information purposes only (“Third-Party Sites”). The Third-Party Sites are not under the control of Coinbase, Inc., and its affiliates (“Coinbase”), and Coinbase is not responsible for the content of any Third-Party Site, including without limitation any link contained in a Third-Party Site, or any changes or updates to a Third-Party Site. Coinbase is not responsible for webcasting or any other form of transmission received from any Third-Party Site. Coinbase is providing these links to you only as a convenience, and the inclusion of any link does not imply endorsement, approval or recommendation by Coinbase of the site or any association with its operators.
All images provided herein are by Coinbase.