This phase netted a lot of big and easy wins! Unfortunately, while all actions were now under a few milliseconds, and the app ran better, our goals were not yet reached. Basic logging was no longer giving actionable information, which meant any remaining CPU cost was coming from React’s commits which run on separate ticks. It was time to figure out how to determine the cost of our component hierarchy.

Phase 2: React Component Optimizations

At first we tried to use the Chrome profiler again since React 16+ snapshots its commits in the profiler. However, it records way more than just React. We were also not interested in the cost on V8 since we were trying to improve performance on JSC. Luckily, both of these could be solved since late last year the React team released a React Profiler that focuses on render time, great!

So we fired up the profiler, hit refresh, and then clicked around. A few things quickly jumped out:

Our React commits could cost up to 200ms when affecting things at the higher levels of the component tree. On top of this, we were going through up to three commit passes when the app was starting up.

when affecting things at the higher levels of the component tree. On top of this, we were going through up to three commit passes when the app was starting up. When viewing the direct message list, we were looping over the whole list on every render to do sorting (some power user’s could have up to 1500 channels).

We had a lot of common views like <Icon> constantly re-rendering despite having no changes in props or state. While these were cheap to render, they added up.

constantly re-rendering despite having no changes in props or state. While these were cheap to render, they added up. FlatList and SectionList , which powered all of our lists, are very expensive and their virtualization is not yet fully optimized. In large servers, for our channel and member lists, they ended up slowly allocating thousands of views which become slow to unmount. Updating our channel list could cost up to 100ms.

Jackpot! There were a lot of actionable items, but not as trivial as the Flux store performance pass.

Commit Passes

Fixing the three commit passes was surprisingly simple. When React Native was released, there was no easy way to get the current dimensions of the screen so we relied on the very first render to trigger an onLayout which we sent to a ScreenStore for others to consume. Luckily, React Native has since improved the Dimensions module by giving it more information and firing events when they change. We made a change so that our stores could initialize with the correct data, avoiding multiple commit passes and even shrinking the view hierarchy. This actually shaved about 150ms off TTI since diffing the tree at the top level was not cheap.

Direct Message Rendering

Fixing the frequent looping over the whole channel list on render was also very trivial, and almost felt silly that we hadn’t fixed this code years ago. We already had a store that maintained a sorted list of the user’s direct messages. This drop in replacement shaved about 2ms per render, but if you were viewing direct messages with a long list of many active users, this would happen quite frequently.

Pure Component

A lot of engineers at Discord used to default to using PureComponent since it avoids renders if props don't change. Unfortunately, it isn't that simple.

When creating a component, one should consider how it will be used and if the props will rarely change, or if the component should just be a pass though and give the rendering decision to its children.

Another thing not always clear to our engineers, was that style and children are just like any other prop and if you are passing a style as an array or dynamically creating style objects, you are breaking PureComponent , the same goes for children . Unless explicitly controlled, any children are just creating new components which never pass an equality check.

With the above in mind we did a pass over our most commonly used Component’s — removing unnecessary instances of Pure and minimizing our use of dynamic styles. All of this work resulted in about 30ms being shaved from rendering when doing common actions like switching channels.

Fast List

Lists have always been a headache in React Native. We’ve actually implemented our core chat view natively because lists don’t perform well for many dynamic rows. We always hoped and assumed that React Native lists were good enough for simpler use cases like our channel and member lists. Unfortunately, despite the React Native team rewriting lists and promising better performance, memory usage and virtualization of the built-in FlatList and SectionList did not meet our expectations.

One of the reasons that built-in lists have performance challenges is because they support a lot of features, and do aggressive pre-rendering as a solution to combat the fact that the underlying <ScrollView> renders in the main thread and JavaScript has to feed it more content as you scroll. What this means is after a <FlatList> mounts, it eventually renders all its rows slowly every frame. Using the Perf Monitor, it could be seen that when looking at large Discord servers, they mounted nearly 2000 views.

Since this is a known pain point in the React Native community, there are already solutions to this problem. We spent some time first testing out react-native-large-list which was immediately a significant improvement, brought down rendering down to 60ms, and only ever rendered about 400 views in large Discord servers. It was not the best to work with because it required restructuring our data that was already computed for both iOS and Desktop in stores, but it was a trade off that we could deal with. Unfortunately, when using the library for much larger lists (eg: 100,000 rows) like channel members, it would lock up the CPU.

Dramatic representation of a recycler view with a pool of reuse-able views.

Next we found recyclerlistview which actually worked even better! It had similar results in the amount of views it mounted, and it was able to keep 60 FPS fill rate on scroll. What was better was that it brought down rendering to 30ms for server switching, and channel switching down to 10ms! Unfortunately, despite trying to trick it via many methods, it suffered from one frame blanks on mount, weird scroll positions when trying to scroll to an item on mount, and the sticky headers implementation conflicted with Animated .

At this point, we were out of open source solutions. So we either had to settle on recyclerlistview or come up with our own solution.

At first, we felt that maybe doing it purely in JavaScript was futile. We spent some time trying to glue together UITableView with React Native, and while we made meaningful progress, it started feeling overly complicated. After stepping back and thinking about what else we could do — it hit us. We already solved this problem once before on the web! We already had an internal List component that virtualizes its children. There is no way we could just drop it into React Native right?

We replaced <Scroller> with <ScrollerView> and <div> with <View> and dropped it in. It worked! Right out of the box it performed almost as well as recyclerlistview and was able to use nearly the same code for rendering as the desktop app. We then spent some additional time adding sticky header support and using some similar techniques employed by recyclerlistview to recycle views - avoiding allocations both on the React and UIKit side.

Before and using our new <FastList> component on an iPhone 6s and an iPhone XS — this yielded significantly less blanking and stuttering on large lists.