Follow Us

Header Ad

Latest Posts

Categories

Optimizing React Native. by Nick Cherry, Staff Software Engineer | by Coinbase | Nov, 2020

Coinbase

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.

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.

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.

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!

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.

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:

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:

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.

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.

    Leave Your Comment

    Your email address will not be published.*

    Forgot Password