React Native was designed to bridge gaps between web and mobile frameworks in software development. Unfortunately, developers face certain hurdles related to performance when working with React Native.
Every framework has its limitations, what matters is how we work around them and build fully functional applications. In this blog post, we’ll take a look at certain features that limit React Native’s performance and how we can reduce their effects to build robust products with this framework.
To understand the problem, let’s first take a look at how React Native’s architecture works. There are three threads that mainly run a React Native app:
Now, the UI and JavaScript threads are individually fast but where performance issues occur is during the communication between both of them via the bridge. Say you are passing huge files between both threads, this could slow down performance. It’s important to keep passes between both sides of the bridge to a bare minimum to avoid any kind of performance-related issues.
Because React has a virtual DOM, it renders JavaScript components asynchronously and in this process, reduces the amount of data that needs to be sent over the bridge. However, this doesn’t prevent a couple of performance issues from springing up from time to time, let’s highlight these issues and how we can fix them.
React Native is single-threaded in nature. In its rendering process, rather than have multiple processes occur at the same time (multithreading), other components have to wait when one component is being rendered.
This proves to be a huge challenge for apps that may want to implement multiple features simultaneously such as a streaming service that needs a live chat feature alongside a live stream feed. High-end devices with more RAM and processing power may get along fine but cheaper devices wouldn’t be able to run apps like Mixer as shown below:
The fix to single-threaded limitations in an app is for engineers to build maintainable extensions that can handle multithreading in a React Native app. An extension lets you provide an app with custom functionality that it would otherwise not have. Extensions can be built using either Java, Swift, or Objective-C. A great example of an extension that fixes the single-threaded issue is one that creates a bridge between React Native and Native components.
When building extensions, it’s important to perform tests on a real device and not just a simulator as real apps are likely to exceed the memory limits of an app thus resulting in memory leaks (which we’ll discuss later in this article). Apple’s Xcode Instruments remains a handy tool for checking memory usage in apps.
Another scenario where single-threaded limitations can be seen in a React Native app is during animation transitions. The JavaScript thread is responsible for controlling navigator animations in a React Native app.
When React Native is trying to render a new screen while an animation is running on the JavaScript thread, it results in broken animations. React Native’s InteractionManager
API is a great way to improve slow navigation transitions.
Let’s say you have an app that does location tracking where users can locate each other by listing location changes frequently. Location changes are listed by initiating a function that searches for a location at a certain time interval.
onChangeTab(event) { if (event === 1) { intervalId = BackgroundTimer.setInterval(() => { this.props.actions.getAllLocationAction(); }, 5000); } else { BackgroundTimer.clearInterval(intervalId); } this.setState({ activeTab: event }); }
This repeated action is bound to create some lag in movement between components. To invoke onChangeTab
repeatedly without slowing down the rendering of the UI, we’ll use the runAfter Interactions()
method in the InteractionManager
API which lets us delay all our operations until all animations are complete:
import { InteractionManager } from 'react-native'; onChangeTab(event) { if (event === 1) { InteractionManager.runAfterInteractions(() => { this.props.dispatchTeamFetchStart(); }); } this.setState({ activeTab: event }); }
React Native apps, both on Android and iOS platforms, struggle to face the issue of memory leaks. Because React Native apps are powered by JavaScript, their memory is managed by the Garbage Collector – a background process that constantly reviews objects and modules and deallocates memory from the ones that are not referenced directly or indirectly from root objects.
In JavaScript, memory is managed automatically by Garbage Collector (GC). In short, Garbage Collector is a background process that periodically traverses the graph of allocated objects and their references. If it happens to encounter a part of the graph that is not being referenced directly or indirectly from root objects (e.g., variable on the stack or a global object like window
or navigator
) that whole part can be deallocated from the memory.
With React Native’s architecture, each module is attached to a root object. Core React Native modules declare variables that are kept in the main scope. These variables may retain other objects and prevent them from being garbage collected.
A common practice in React Native apps that can lead to memory leaks is improper handling of closures. Closures are functions that capture variables from parent scopes. Check out the code sample below:
var thisList = null; var replaceList = function () { var originalList = thisList; var idle = function () { if (originalList) console.log("nice"); }; thisList = { thisArray: new Array(2000000).join('*'), thisMethod: function () { console.log(thisMessage); } }; }; setInterval(replaceList, 1000);
In the above code sample, for every time replaceList
is called, thisList
gets an object which contains an array (thisArray
) and a method thisMessage
. Simultaneously, the variable idle
holds a closure that refers to originalList
which refers to its parent function replaceList
. The scope created for the closure thisMethod
is shared by the variable idle
, which — even though it is never used — its indirect reference to originalList
makes it stay active and unable to be collected by the Garbage Collector.
Thus when replaceList
is called repeatedly, a steady increase in memory usage can be observed which doesn’t get smaller when the Garbage Collector runs. What this means is that each of the linked lists of closures created carries an indirect reference to thisArray
thus resulting in a costly memory leak.
Fortunately, fixing memory leaks that occur as a result of closures is straightforward. Just add originalList = null
to the end of replaceList
. So even though the name originalList
is still in the lexical environment of thisMethod
, there won’t be a link to the parent value thisList
:
var thisList = null; var replaceList = function () { var originalList = thisList; // Define a closure that references originalList but doesn't ever // actually get called. But because this closure exists, // originalList will be in the lexical environment for all // closures defined in replaceList, instead of being optimized // out of it. If this function is removed, there is no leak. var idle = function () { if (originalList) console.log("nice"); }; thisList = { thisArray: new Array(2000000).join('*'), thisMethod: function () {} }; // If you add `originalList = null` here, there is no leak. originalList = null }; setInterval(replaceList, 1000);
In the code sample above, while originalList
is accessible to thisList
, it doesn’t use it. But because originalList
is a part of the lexical environment, thisMethod
will hold a reference to originalList
. Thus even if we are replacing thisList
with something that has no effective way to reference the old value of thisList
, the old value won’t get cleaned up by the garbage collector. If you have a large object that is used by some closures but not by any closures that you need to keep using, just make sure that the local variable no longer points to it once you’re done with it.
React Native is an awesome framework that fuses web and mobile development. Applications can be written for Android and iOS devices using just one language – JavaScript. Though it may have shortcomings with impacting on the overall performance of an application, most of these shortcomings can be avoided or improved upon to create an overall better user experience for mobile apps.
LogRocket is a React Native monitoring solution that helps you reproduce issues instantly, prioritize bugs, and understand performance in your React Native apps.
LogRocket also helps you increase conversion rates and product usage by showing you exactly how users are interacting with your app. LogRocket's product analytics features surface the reasons why users don't complete a particular flow or don't adopt a new feature.
Start proactively monitoring your React Native apps — try LogRocket for free.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowExplore use cases for using npm vs. npx such as long-term dependency management or temporary tasks and running packages on the fly.
Validating and auditing AI-generated code reduces code errors and ensures that code is compliant.
Build a real-time image background remover in Vue using Transformers.js and WebGPU for client-side processing with privacy and efficiency.
Optimize search parameter handling in React and Next.js with nuqs for SEO-friendly, shareable URLs and a better user experience.
3 Replies to "Overcoming single-threaded limitations in React Native"
In the screenshot of Mixer, the chat is entirely native. You could argue the video is too with just some React Native container. Not sure what Android used, but on iOS I used UICollectionView. This is similar to what other apps do that are hybrid React Native / native apps.
Great. Very good article.
The animation by JavaScript is deprecated, you can use native animation by using Animated library that use native driver.