React Native

Measuring and improving performance on a React Native app

The web has several tools, such as Google Lighthouse, to measure performance. But what about mobile apps?

This article will present a Flipper plugin we use to measure our React Native apps performance, with a concrete case of how we used it to bring our app performance to the top quality.

Let's talk about fps

One of the key metrics to measure the performance of your mobile app is the frame rate. You want your app to run at 60fps (frames per second) to give an impression of smoothness.

This video from Google explains it well:

  • 60 is high enough for good perceived smoothness
  • there are less benefits in higher numbers
  • your frame rate should be constant and not drop

What about React native?

This is true for any mobile app, and so this is true for a React Native app as well.
But there's an added complexity: you also have to take into account the JS thread

RN architecture

The JavaScript is driving the UI, so if it's not performing well, especially if it goes down to 0, your app will start to feel or be unresponsive.

For instance, if a user presses a button on your app:

  • this generates a touch event on the native side
  • this will be sent over the bridge
  • on the JS side, it will go to your ++code>onPress++/code> function and run logic accordingly
The path of data in RN

But if your JS thread goes down to 0, this means it's using a CPU at max capacity already for other calculations and can't possibly handle your ++code>onPress++/code> at the moment.

You can find more details in the React Native docs (https://reactnative.dev/docs/performance#what-you-need-to-know-about-frames)

MEASURING UI and JS FPS

React Native provides the Perf Monitor out of the box (you can enable it from the React Native development menu on your Android device)

 

RN Perf monitor

But since you only see the values at a given moment, it might be tricky to assess how your app is performing, or if some changes you make actually impact the performance.

This is why we created a flipper plugin to profile those FPS data over time: https://github.com/bamlab/react-native-performance

Our performance plugin

As an added bonus, it gives you a performance score, aiming to be similar to Lighthouse for the web.
This is how the score is calculated below, quite naively, but open to suggestions:

The plugin scoring

Here is how to make the most of this plugin for your app. You'll also see how we used it in conjunction with the React Devtools to find and solve some performance issue in our app.

The 4 essential tips to measure performance

1st advice: use a lower-end device

Often, developers (myself included) test their apps on high end phones or mostly in the iOS simulator.

But the easiest way to find performance issues is to try out your app on a lower end Android device. My advice is that you set the "minimum phone" for which you want your app to work smoothly. Measuring on this phone will accentuate performance issues but also performance improvements you might implement.

meme-homer-1

At BAM, we're using a Samsung J3 from 2017. This was one of the most sold devices in France in 2018.

j3-ftw

It scores a measly103 in single score CPU performance on Geekbench.

It might seem bad compared to an iPhone 7 scoring 700 for instance. But on most of our apps, we're able to estimate that 15% of our users actually have a worse device in terms of performance (we compared metrics from Firebase performance to estimate this).

  • Since 15% of our users will have a worse experience than on that phone, it seems reasonable to ensure the app performs well on that phone.
  • This will also be beneficial for higher end devices, because better performance will translate to less CPU/Battery usage for all devices
  • By focusing on lower end devices, you also help fight against planned obsolescence, which is one of the way that we, as devs, fight against climate change.

Improving performance is a way we, as devs, can fight against climate change

2nd advice: make your measures as deterministic as possible

Measuring performance is tricky because it's never going to be 100% deterministic. So you should set measuring conditions in order to make your measures as deterministic as possible.

  • Average your measures over several iterations.
  • Have the same conditions for every measure (duration of the measure, first launch or not, network..)
  • Automate the behavior you want to test.

Luckily, the last point can be made easy on Android: it's indeed possible to script touch events with ++code>adb shell++/code> commands.

For instance, scrolling down on Android can be automated with a command similar to:

++pre>++code># adb shell input swipe <x1><y1><x2><y2> [duration(ms)]
adb shell input swipe 500 1000 500 0 50
++/code>++/pre>

The command above creates a swipe movement from the (500, 1000) coordinates, so the bottom of the screen, to the (500, 0) coordinates, so the top of the screen, during 50ms. Basically this will scroll down quite fast!

I invite you to try out running adb shell input and see everything that is possible for yourself:

++pre>

++/pre>

You can swipe, input text, click...  on your Android phone, all from the command line! ?

3rd advice: disable JS Dev Mode

To measure performance, it's advised by the React Native devs to disable the JS Dev mode. To do so, open the development menu, and go to settings.

Otherwise, you might spot issues that won't be existing in the released version of your app.

JS Dev Mode in the development menu

4th advice: use the correct tools to analyze your issues

If you have issues with:

A concrete case: the TF1 Info app

TF1 is a French TV channel and while developing their news app, we wanted to ensure our performance was on par with our quality standards.

Our goal was to ensure that on our lower end Android device:

  • UI FPS would stay close to 60
  • JS FPS would never go as low as 0

Below is how our team of 4 devs: Delphine, Antoine, Yang and myself iterated on our app to match our standards.

Measuring with the Flipper plugin

We measured the performance of scrolling on our Home page. It is implemented with a FlatList composed of several different components.

tf1-info-home-page

To measure: we use the Samsung J3 2017, our favourite lower end device and reproduce those steps:

  • Setup measuring for 10s on the Flipper plugin
  • Reload our app with JS dev mode disabled
  • Click "Start measuring" on the Flipper plugin
  • Run ++code> adb shell input swipe 500 1000 500 0 50++/code>  to trigger a fast scroll
  • Wait for the Flipper plugin to end measures after 10 seconds
  • Reproduce 5 times

 

Aaaand all our measures looked basically like this:

First trace 40/100
  • The score average was 40/100 ?
  • The JS was hitting 0 for 3.5-4s ?
  • The UI thread was dropping a bit below 60 from time to time ??

Needless to say, this wasn't acceptable. The biggest issue seeming to be on the JS side we went over to the React Devtools profiler in Flipper to analyze the cause of the issue.

Analyzing JS issues with the React Devtools profiler

The best tool to analyze JS issues is the React DevTools profiler, and you can open it from Flipper directly.

To make it show up, you'll need to re-enable the JS Dev mode.

React Dev Tools in Flipper


The first thing you want to do is activate this neat option in the settings:

Record reason of rendering option

This will be useful pretty soon!

So for the TF1 Info app, we basically repeated the steps we did previously:

  • Reload our app (with JS Dev enabled mode this time!)
  • Click the recording button in Flipper
  • Run  ++code>adb shell input swipe 500 1000 500 0 50++/code>  to trigger a fast scroll
  • Click the recording button to stop recording again

On the top right, you will see "commits". The React documentation is great at explaining how to make the most of it.

In our case, let's click on the most expensive commit, this is usually where you'll find your performance issue

Here's what it looks like:

React Dev tools first trace

Everything that is gray is not rendering, so no issue there. The rest is rendering though!

The first non gray component here is the VirtualizedList (a direct child of FlatList), and it takes 2999.2ms to render!

What's crazy is that we can see that all of its children are rendering and it's quite expensive! ?

We'll see below the 4 iterations we made to improve our performance.

1st iteration: A useful optimization for all RN apps

Under each FlatList, you can see its "CellRenderer" children. Each of those corresponds to an item in your list and will contain what you pass in ++code>renderItem++/code> (in our case a component called ++code>Block++/code>)

So here, we see that all of the items in the list seem to be rendering. We might think, that it is because when we scroll, new items appear and so they are rendered for the first time. That would be expected then.

But by stepping over one of the CellRenderer, we can see it's not rendering because it's rendering for the first time, it's re-rendering because of props change. (We see this because of the option we activated before)

 

Sooo, when we scroll down, basically all items in our list seem to render?
Actually this is a common issue with ++code>FlatList++/code>. To be able to virtualize and not render the whole list of items, they're keeping a React Context of the "window" of items to be rendered, aka the first item index and the last item index that need to be rendered.

virtualization window

(Note that by default in a React Native FlatList, the window of items to render in your list has the size of 21 viewports, to ensure smooth scrolling. That was a lot more than I first imagined ?)

This means that when you scroll, your items are rerendered by design, because of the context change. So it's extremely important to follow good practices defined here. The easiest thing is to memoize the component you pass in ++code>renderItem++/code>.

Good moment to mention that I'm looking forward to the day where everything will be memoized out of the box in React, as teased in this talk.


Indeed, after memoizing, we can see some improvement.

DevTools - 2nd trace

The biggest commit seem to be around 2.7s instead of 3s before. A lot more stuff is grayed out, which means less components are rendering. However, a lot of components (some children of our list items) still are rerendering while they ideally shouldn't.

Before moving to the next improvement, let's verify that we really did impact performance with this change, even if the JS Dev mode is disabled.

You know the drill, let's do the same measures as before with the performance monitor plugin.

This time, the measures looked like this:

Perf trace 52 100
  • The average score went up from 40/100 to 52/100 ?
  • The average JS threadlock time went down from 3.7s to 2.8s ?
  • The UI FPS stayed pretty much the same

Ok, so with a simple fix (just a well placed ++code>memo++/code>), we're already seeing some pretty good changes! ?

2nd iteration: the joy of nested lists

Bringing back the previous graph, as mentioned before, we can see that some items in the list are still rendering.

DevTools - 3rd trace

If we look closer we realize that nested ++code>VirtualizedList++/code> inside our vertical list items are rerendering.
Indeed we have horizontal carousels with react-native-snap-carousel and they use horizontal Flatlist. In the graph above, we see we have 7 carousels re-rendering (the 7 green columns basically), each having several items.

tf1-carousel

Well, remember the virtualization context window we talked about before? It also causes the nested virtualized list to fully rerender. And this is by design, because the nested Virtualized List virtualization window depends on the parent list virtualization window ?

What are we to do then if we have nested VirtualizedList? We can start by doing exactly the same thing as before and memoizing our nested list items. In our case, that would be the Carousel items.

Here's the result:

DevTools 4th trace

At first glance, not a lot has changed. We have new items rendering on the right, but they're rendered for the first time, which is to be expected when we scroll down.

First time component rendered

But on the previous graph, all of the children of the nested lists were green and thus re-rendering. Here the children at the very bottom (our carousel items) are grayed out and not re-rerendered. So that seems like a nice improvement right?

Let's check the score on the performance monitor plugin with production JS:

Perf trace 54 100

Sadly, not of a lot of improvement here actually:

  • Average score was about the same as before ?
  • Average JS threadlock time was still around 2.8s ?
  • UI thread still about the same ?

3rd iteration: reducing carousel render

This brings us to our next iteration. Going back to the React Devtools, if we click on a nested list in particular (one of our carousels), here's what we see:

Closer look at a Carousel

We can see that this carousel has 10 children re-rendering. But actually, when I check in the app, it only has only 4 items! ?

This is because we enabled the ++code>loop++/code> property on the carousel (see the prop doc) which works by adding 3 (by default) item copies at the beginning and at the end of the list.

This checks: 3 + 4 + 3 = 10 ?

So we decided to remove this feature and check the result:

DevTools with loop={false}

Quite nice, we have way less items re-rendering so our vertical list takes less time to re-render (only 682ms this time)

Let's check our score!

Perf trace 70 100

Great improvement this time ?

  • Score hit the 70/100 mark on average ?
  • JS threadlock went down to 1s on average ?
  • UI FPS stayed ok ?

We're getting there, but it's still not great. The JS hitting 0 means that for 1s, the app will be unresponsive.

Last iteration: a non-intuitive fix?

Going back to the previous Dev Tools graph, what's annoying is we sill have a lot of green. And even if we get rerendered per design of the virtualized list, we shouldn't have so many components re-rendering right?

Let's take a closer look at one carousel:

Carousel closer look 2

When scrolling down, we can see the 4 ++code>CellRenderer++/code> rerendering (as expected) but there's a lot happening between the ++code>CellRenderer++/code> and the actual Carousel items in our code.

And we can see that this is costly to re-render. The ++code>AnimatedComponent++/code> highlighted in yellow especially indicate that this component in particular took a lot more time to render compared to their children.

++code>react-native-snap-carousel++/code> adds this ++code>AnimatedComponent++/code> between the ++code>renderItem++/code> of the virtualized list and the actual item we provide. So even if we memoize our item, this ++code>AnimatedComponent++/code> will still be re-rendered since ++code>react-native-snap-carousel++/code> doesn't memoize it (and it should)

 

We have 2 solutions then.

The first one is to patch ++code>react-native-snap-carousel++/code> and add the memoization ourselves.

The second one is to wonder if we need virtualization on our carousel in the first place.
Remember, all this is happening because the virtualization list context is causing nested list to rerender. The lesson here is having nested lists can be tricky to handle, performance-wise. Can we avoid it then?

The answer for us is YES. By default a virtualized list will render enough item to fill 21. So for our horizontal lists of 4 items, virtualization actually has no benefits!

We eventually decided to rewrite the carousel ourselves using a ++code>ScrollView++/code> . It's not as complex as I previously thought!
But note that ++code>react-native-snap-carousel++/code> also provides the option to disable virtualization with the prop ++code>useScrollView++/code>

And here, finally, all existing items are not re-rendered when scrolling down.

DevTools final

And you know what I want to do now ? Here comes the performance score:

Perf trace 90 100
  • Hitting the fabled score of 90/100 ?
  • No JS threadlock ?
  • UI FPS stays around 60 ?

 

Wrapping up

Thanks to our Flipper plugin and the React Dev tools, we were able to bring considerable performance improvement to our app, with a performance score going from 40/100 to 90/100.

Let me know what improvement you bring to your apps with those tools! ?

If you have any questions about the Flipper plugin, feel free to ping me on Twitter or post a Github issue

I'm still dreaming about a having a tool on par with Lighthouse for the web, hopefully, this is making it closer. But there are still lots of things that could be done to perfect it, expect new improvements soon!

Développeur mobile ?

Rejoins nos équipes