1. Introduction to Mobile App Performance
Hi, everyone. I'm Alex, a tech lead at BAM. Let's talk about mobile app performance. Good performance means running at 60fps, drawing 60 images per second. In React Native apps, the JS thread can cause unresponsiveness. Use the React Native Performance Monitor and Flipper plugin for monitoring. Test on lower-end devices and make performance measures deterministic. Automate behavior to reproduce conditions.
Hi, everyone. Super excited to be talking to you at React Summit. I'm Alex. I'm a tech lead at BAM. We develop mobile apps in Kotlin, Flutter, and of course, React Native, and I just love the subject of mobile apps' performance, so let's dive in.
What does it mean for a mobile app to have good performance? Well, according to this video from Google, which will be available in slides, your apps should be able to run at 60fps, 60 frames per second. This means that your app should be able to draw 60 images per second when you scroll down, for example, to give an impression of smoothness to the user. It's basically like a movie. It's animated pictures, basically.
The question is what about React Native apps? Well, this also applies to React Native apps, but there is an added complexity, the JS thread. Because most of your logic will run on the JS, most of your business logic probably resides on the JS side of things, you need to make sure that it's not too busy. For example, here I have this app with a Click Me button. When I click it, the state updates. I've been clicked one, two, three times, but Kill.js is too expensive, so when I click it, the JS side of things is blocked. So, even if I click Click Me a lot of times after clicking Kill.js, nothing happens, and it's not before Kill.js has finished that the JS actually becomes responsive again, and you can see it update four to 12 times like that. So, it's very important to take a look at the JS side of things as well, because your app could be running natively at 60 FPS but be completely unresponsive. This is why React Native offers this view, the React Native Performance Monitor, displaying Ui and JS FPS, and this is why we created this Flipper plugin, to be able to display it in a graph. Also, as an added bonus, it gives you a nice score for you to be able to run performance benchmarks. But, chances are, this score could depend on a lot of factors, actually, so let me give you some general tips on performance measures.
2. Tips for Performance Analysis
To ensure accurate performance measures on the JS side, disable JSDev. Use React Devtools and JS Flame Graph for analysis. On the UI side, utilize Android Studio, Systrace Profiler, or Xcode Instrument.
You can just use ADB on Android, for example. Here, with ADB Shell Input Swipe, you can actually trigger a scroll on your app. Third tip is this. Disable JSDev if you want to have true performance measures on the JS side, you should disable this, because you might encounter an issue that you won't actually see in production. And the fourth tip is, well, you should find some issues when measuring. Just use the best analysis tools. On the JsThread, that would be React Devtools, or even running a JS Flame Graph with the Hermes profiler. And on the UI side, use native tools like Android Studio, Systrace Profiler, or Xcode Instrument on iOS.
3. Performance Test on TF1 News App
TF1 news app performance test on a low-end device. Goal: JSFPS above 0, UIFPS at 60. Used Samsung J3 2017. Measured for 10 seconds, reloaded with JSDev mode disabled. Plugin score: 40/100. JS unresponsive for 4 seconds. UI thread okay.
All right, let's dive in on a concrete example now. TF1 is a TV news channel and we're building their news app, and we wanted to make sure that performance was on par with our quality standards. So we checked the home feed and our goal was that when we scroll down, basically on a low-end device, we should have JSFPS always above 0 to keep the Js side of things kind of responsive, and the UIFPS always to 60 to be able to be very smooth.
So following the four essential tips that I talked about before, basically we use a Samsung J3 from 2017, it's our favorite low-end device, it's pretty low end. We set up measuring for 10 seconds, arbitrary, but it's just important to keep the same time for every measure so you're able to compare. We reload with JSDev mode disabled. We wait for the feed to be loaded, and we hit Start Measuring under the Flipper plugin. We run ADB shell and PutsWide, blah, blah, blah, and we wait until the end of the 10 seconds, and then we reproduce this 5 times just to be able to see if we get a similar result and average it. And the result was this. The plugin was giving us a score of 40 out of 100, JS was dead at zero for about 4 seconds, UI thread was kind of okay. But basically, this means that the app was actually not responsive for about 4 seconds, which, needless to say, was completely unacceptable.
4. Analyzing JS Performance with React DevTools
To analyze the JS performance, use React DevTools in Flippr. Click the button on the top right, check the box for live profiling, and start recording. Reproduce the desired actions, stop recording, and review the results.
Since the issue was mostly on the JS side of things, we ran two React DevTools, and it's available out of the box in Flippr, which is nice. First thing you want to do is click on this button on the top right, and there you can check the box, record why each component rendered live profiling. This is a neat option to activate. We will see why pretty soon. Then just click the blue button here to start recording. Do some stuff, so in our case that would be reproducing the exact same scrolling with adb shell input swipe blah blah blah. And then we hit the button Stop Recording, and it gives us a result kind of like this.
5. Analyzing React Component Hierarchy and Rendering
The hierarchy of React components shows the rendering status of each component. The virtualized list is the main component, with several child components called cell renderers. Each component has self time and total time metrics, indicating the time taken to render without and with children, respectively. In this case, the virtualized list takes 3 seconds to render, including all list items.
All right. First thing you want to do is check this. This is the list of comments. These are the phases where React actually applies any changes. And so usually when you look at performance issues you want to find the most expansive comments in terms of how much time it took. And in our case this would be the 11th one. It's the biggest bar here. So let's click on it, and this is what it gives us.
Pretty. There's a lot of colors here, but basically this is the hierarchy of your React component. In grey here, you will see components that are actually not rendering, but in our case the red screen and some other colors is rendering. So if we zoom in a little bit on this part for example, you will see the hierarchy of components starting with flatlist here in grey. So not rendering. So our home feed was implemented with flatlist so this is it. And it's an internal implementation in the React Native code. Its child is called a virtualized list. So here it is. Then we have context.consumer, its child is virtualized list.contactProvider etc, all the way down to context.provider, just a list of children basically. And this guy has several children called cell renderers. This is basically what is wrapping the stuff you pass in your render items. So essentially the cell renderer are our list items of flatlist. You will notice also that on every component you have two metrics. You have this one on the left side, for example here 4.9 milliseconds on our virtualized list. It's the self time, it's the time the component took to render without any of its children. And the right one is the total time, it's the total time that the component took to render including all of its children.
So here basically we have a virtualized list taking 3 seconds to render. And we can see that basically all of the list items are actually also rendering. So our list is rendering, all of the items in our list are rendering, and this takes a total of 3 seconds. Which is kind of crazy. I mean remember when we talked about the JS being locked for about 3 or 4 seconds? This totally explains it, we have 3 seconds for that.
6. Optimizing FlatList Rendering
Differentiate between initial rendering and re-rendering. The FlatList is a virtualized list that renders a subset of items. Memoize list items to prevent unnecessary re-renders. Gray items are memoized and no longer re-render. Green items are part of the internal implementation and cannot be prevented from rendering. Performance score improved from 40 to 52, with only 3 seconds of unresponsiveness.
So what should we do? Well, the first thing that you want to do is differentiate between initial rendering, and when you hover a component, since we activated this new option earlier, you will see something like that. This is the first time the component rendered. Or are those components actually re-rendering? They have been initially mounted, but they're rendering a second time or another time. And in this case you will see something like this, a virtualized list, it's a state change, a last change, which we don't really know about because it's the internal implementation of FlatList.
In our case, actually, all of the components here were re-rendering because, remember, I said that we had loaded our feed previously so it was rendering already some items in the feed, so when we scroll down, basically it's re-rendering even the top items in the feed. And this is crazy, right? They don't need to be rendered. So what is going on? Well, the first iteration we took to fix was this. And everyone should know about this. So the FlatList is a virtualized list and virtualization works kind of like this. You have a long list of elements, for example, I don't know, 10,000, but of course, for performance reasons, you're not going to be able to display them all at the same time. You use a virtualized list to display only what the user sees and some items above and some items below to ensure smooth scrolling. It's important to notice that in React Native, this is the FlatList property called window size and in React Native, actually, it's going to render 10 screens' worth of items above the current viewport and 10 screens below it. So it actually renders a lot of items. It's a good thing to keep in mind. But so how does this work? Well, basically, the FlatList is keeping a state in its internal implementation of the first element to render and the last element to render. So when we scroll down, well, those change. And so this triggers a re-render of the virtualized list, which is normal. So basically, this means that render item, what you pass in your render item is called onScroll. This is by design, which means you should absolutely follow the best practices defined in the React Native documentation about FlatList, and you should memoist all of your list items. And that's what we did, and we went from this to this.
So the big difference that you should notice is this. Basically, we have a lot of gray here. The items that we memoist are now not re-rendering, and this is what we want. We still have some green stuff above it. This is basically the internal implementation of virtualized lists, state changes, etc. So we can't really prevent it from rendering, and what is really expensive here is the children, and we still have some green stuff here. We'll get to that in a minute, because first, we already have some less green stuff, we have some more stuff, I mean, we have less stuff rendering, so, let's just check our performance score. Let's check our measures, just like before on the Flipper plugin, and this is what it gives us now. We went from 40 to 52, Chase is only dead for like 3 seconds, and the UI thread is still around 60. So ok, it's not fantastic, but already, just with a simple memo, we made a very good improvement for performance.
7. Exploring Nested Lists and Carousel Rendering
Let's dive deeper into nested lists. The green items at the bottom are not virtualized lists, but horizontal carousels implemented with React Native Snap carousels. These carousels, along with the parent list, re-render when scrolling due to context change. To optimize performance, we memoized the slide items, resulting in more gray at the bottom.
But let's go deeper. Second iteration, I titled it the Joy of Nested Lists, and you will understand why pretty soon, because, yeah, remember those green stuff at the bottom? Let's zoom in to see what this is. So we have our cell render at the top, so this is wrapping what we pass in our render item, then we have the gray stuff, because we memoized it, but we have here something that is still rendering in green. And it's not a virtualized list. This is because we have horizontal carousels implemented with React Native Snap carousels, which itself uses a flat list. So essentially here you see our seven carousels that are displayed on our home feed, re-rendering. But why are they re-rendering when we scroll down? Well, remember the state, the window state we talked about before for the parent list? It passes it as context to the nested child list. So we can see here that the virtualized lists are re-rendering because of context change. And this means that when we scroll the parent list, nested lists, even horizontal, the render item will also be called. So we have to memoize everything, and in our case that would be the slide items. And so that's what we did, and we went from this to this. Might be hard to see, but now we have a lot more gray at the bottom.
8. Optimizing Carousel Rendering
We observed new list items appearing when scrolling down, which is expected. However, excessive re-rendering resulted in minimal improvement in performance. Upon further investigation, we discovered that the React Native Snap carousel's loop prop was causing additional slides to render. By deactivating this prop, we reduced the number of slides and significantly improved performance.
So oh, we have some stuff appearing on the right side. We can check what this is. Okay, there are actually re-rendering for the first time, which means that this is probably new list items appearing when we scroll down. This is okay.
Let's check first excessive re-rendering. So now we have this. Lots of gray at the bottom. Let's check more score. And we went from 52 to like 54. Pretty much the same, basically. Not super good improvement. So we need to go deeper.
All right, let's check a bit more those carousels. If we zoom in on this guy here, we can see that basically it's a virtualized list re-rendering. Okay with our carousel, we've worked 10 slides. Wait, 10 slides? No, actually our carousels only have 4 slides. Oh, but that's right. We added the loop prop on the React Native Snap carousel, which means that it will actually add 3 slides on each slide of your carousel at the beginning and at the end to achieve the infinite loop effect. The brightest among you will have noticed that this checks, this equals 10. So we decided to just deactivate this prop for performance reasons.
So now we went from this to this. We can see that indeed we only have 4 slides rendering. So let's check our score. Our score is much better. We went to 70. C, GS, is only that for one second. Great, great. Still OK. All right. We're getting there, but still not perfect.
9. Optimizing Virtualized List and Carousel
Let's dive deeper into the virtualized list and identify the expensive animated component. By avoiding nesting virtualized lists and recoding the carousel with a scroll view and paging enable prop, we significantly improve performance. Our score is now 90, with no zero JS FPS and a consistently responsive UI thread. Try measuring your app's performance, identifying and fixing issues, and tracking score improvements. Thank you for watching!
So let's go even deeper. Right, let's click on this first carousel, this virtualized list. We can see all of the items. If we zoom in on the first one, this is what we see. We see our virtualized list here. Still taking 75 milliseconds to not do anything, basically. But we noticed this guy, animated component being in orange. React DevTools will print out, in a different color, like orange here, items which have a very big self-time. So they're very expensive to render, compared to their children. And so this guy here is expensive to render.
But where is it coming from? So the top here is from the flatlist code, from virtualized list to cell render. This gray stuff is our slide. It's memoized. So here, this is coming from React Native's natural cell. Actually, the animated component is here to have some cool, nice transition. But not only is it quite expensive to render, it's not even memoized, and it should be. So we could patch React Native's natural cell. Or at this point, we kinda realize that nesting virtualized list is tricky. So the question is, can we avoid it? And the answer is yes, because remember what I said about virtualization by default in React Native displaying 21 screens worth of items. In our case, we only have four items, so virtualization here for carousel brings no benefit whatsoever. So we decided to recode it ourselves with a scroll view and paging enable prop, basically. And now we have only gray here when we scroll down. Nothing is re-rendering excessively.
So let's check our score. Now we're at 90, and the JS is never at zero, and the UI thread is always okay. Woo! We've done it. So I encourage you to try it out, measure your app's performance, take a low-end Android device, find areas with the plugin with zero FPS on JS side or below 60, analyze those issues and fix them, and check back your score improvement. That's it. Thank you guys for watching, hope you enjoyed the talk. If you have any questions, feel free to ping me on Twitter or follow me, I tweet mostly about React Native Performance.