Hunting JS memory leaks in React Native apps

Krzysztof Magiera
Software Mansion
Published in
15 min readJul 26, 2018

--

At Software Mansion we are often asked by our clients to review their React Native apps for possible performance improvements. One of the most common reasons of performance issues are memory leaks. In this article, we gathered the typical approaches to debug and solve memory problems in a React Native app. If your app happens to suffer from memory issues, you will learn how to tell if your app is leaking memory, and if so, how to pinpoint and fix the source of the leak.

Memory leaks in JS

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.

It is a common misconception that languages which rely on Garbage Collection (GC) for memory management (like JavaScript) are resilient to memory leaks.

In React Native world each JS module scope is attached to a root object. Many modules, including React Native core ones, declare variables that are kept in the main scope (e.g., when you define an object outside of a class or function in your JS module). Such variables may retain other objects and hence prevent them from being garbage collected.

Here is a list of most common mistakes in React Native apps that can lead to memory leaks:

1. Unreleased timers/listeners added in componentDidMount

This is a no. 1 reason for memory leaks in React apps in general. Let’s look at the following component that listens to keyboard showing and hiding events:

Here, we register listeners to keyboardDidShow and keyboardDidHide events to keep current keyboard status as astate of the component. The listeners are registered when the component gets mounted. Since we never remove these listeners, they will still receive keyboard events even after component gets unmounted. At the same time, Keyboard module has to keep the list of active listeners in global scope — in our case, it will retain the arrow functions we pass to addListener method. In turn, these arrow functions retain this – i.e., the reference to the Composer component, which in turn references its properties via this.props, its children via this.props.children, its children’s children, etc. This simple mistake can lead to very large areas of memory left retained by accident.

The above should be resolved by properly removing listeners in componentWillUnmount:

Always remember:

If your component registers listeners or uses setTimeout, setInterval or similar method to run a callback function, you have to make sure that these listeners and callbacks are properly cleaned up when component unmounts.

One of the strategies that will help you avoid problems like this is to place registering and unregistering logic in a wrapper component that provides the data from events to its children via props. This way we will limit the number of places in our codebase where we need to register and unregister listeners. As an example we recommend you to check out this article by Eric Kim. It describes a simple HOC-based approach that wraps Keyboard callbacks.

2. Closure scope leaks

Closure scope leaks are much more difficult to avoid — but not necessarily harder to track down.

A closure scope is an object containing all the variables outside of closure that are being referenced from within that closure.

Let’s imagine we want to build a countdown timer component. It takes initial time as props and keeps the current time that is left in its state. To update the timer, we will use setInterval in componentDidMount:

In componentDidMount we create an instance of arrow function (closure) that will update state. In order for that arrow function to work, it needs to refer to some variables from the scope where the function is defined, namely hours, mins, sec and this (as it uses this.setState or this._interval). In Javascript VM an arrow function instance is represented as an object that retains references to all the variables it “captures.” Note that countdown variable is also defined in the same scope as the arrow function, but since it is not “captured” by the arrow, it won’t be retained by the function.

Now imagine that we would like to handle prop updates — in this case, to reset the countdown to values passed as the new props. We will use componentDidUpdate lifecycle method for that. Now, if any of the countdown related props changes (that is countdown, hours, mins or sec), we will restart interval callback:

In componentDidUpdate we use the same code to create an interval callback, so we expect it to retain exactly the same variables as before. However, this isn’t the case: our arrow function will retain prevProps in addition to the other referred variables.

The reason for prevProps being retained is that in a given parent scope Javascript VM will only create a single scope object that is then shared between all closures created in that parent scope. In this case, the scope object is shared by all closures defined in componentDidUpdate, so it will retain all variables used in at least one closure there. Unfortunately, there is a closure in line 3 that captures prevProps. Even though that closure is not being used later on and is definitely not being retained after it is used to calculate clockPropsHasChanged, it still makes all the other closures retain prevProps.

Note that capturing prevProps in componentDidUpdate may in some cases have serious consequences, especially when our component has children. In such case, we will end up capturing references to unmounted children via prevProps.children.

As our experience shows, lifecycle methods that accept previous state or props (i.e., componentDidUpdate or getDerivedStateFromProps) are the most common source of memory leaks via closure scope in React apps.

There are a handful of ways how that kind of scope leak can be eliminated:

  1. We can assign prevProps = null right after we are done with calculating clockPropsHasChanged.
  2. We can extract method responsible for comparing props and define it at the component level, then pass prevProps to it and call it as follows: clockPropsHasChanged = this.compareClockProps(prevProps). This way the closures from our example are going to be created in separate scopes.
  3. Similarly, we can extract a method that creates setInterval callback, e.g.: this.createUpdater(hours, mins, sec)

Does my app leak memory?

Usually, it is quite difficult to tell if the app is leaking — especially that sometimes the leaks are so small that it is virtually impossible to notice. The best approach is to analyze a workflow within your app that you would expect to be memory neutral, i.e., a sequence of steps that should not lead to any new objects being retained. For example, navigating to a new screen and going back to the previous, or adding and removing items from a list are both scenarios that in most cases should not increase memory usage. If such a workflow leads to leaking, you should notice your app’s memory consumption to grow after you repeat it several times.

The easiest way to observe that is by using Instruments on iOS or Android Studio Profiler tab for Android. Both of those tools show the total memory usage of the app — this includes both memory allocated by JS, as well as the memory allocated by native modules and views. Let’s take a look how to use them:

I. Monitoring memory usage on iOS

After launching the app from Xcode, go to “Debug navigator” (step 1) and select memory section (step 2):

Opening memory monitor in Xcode

Use your app for a while and see how memory usage behaves:

Memory usage grows when screen is opened but does not get back to normal when we close the screen

If the memory usage increases after a sequence of memory neutral actions, the app is likely leaking memory.

2. Monitoring memory usage in Android Studio

When testing your app on Android, you can use Android Studio Profiler tools. First, open your project with Android studio. When you have your device or emulator connected, and your app launched, you should navigate to the “Profiler” tab on the very bottom and select “memory” section:

Monitoring memory usage on Android

Inspecting JS memory with Safari

It’s occurred to me recently that not many people know that React Native apps can be inspected using Safari Web Inspector tool. It has pretty decent support for memory snapshots: it will let you compare older and newer snapshot to filter out objects created and retained recently. A big advantage of using Safari Web Inspector over React Native’s Remote Debugging is that we will interact with the same type of Javascript virtual machine as our app is running in production (i.e., JavaScriptCore, instead of the V8 engine used when debugging using Chrome).

Following steps will connect Safari Web Inspector to your React Native app running on iOS:

1. Enable “Develop menu” in Safari

Launch Safari on your Mac and navigate to the “Advanced” section in settings. Make sure that you have the following field checked (“Show Develop menu in menu bar”):

Enable develop menu in Safari settings

2. Connect Web Inspector to JSC context running on simulator or Device

When your React Native app is running on a connected device or simulator, you should be able to see Javascript context (JSContext) under “Develop” menu in the second section:

When you select “JSContext” option from the menu, Web Inspector will launch and attach to the running app:

Safari Web Inspector attached to running React Native app

Note that if you reload your app (e.g., by using “reload JS” button), a new context will be created. If you had Web Inspector window open, it will no longer be connected to an active context. You need to open new Web Inspector window in such case.

3. Taking memory snapshots

Unfortunately, Safari Web Inspector does not show live stats of memory usage. If you want to see how much memory is being consumed, you can take a memory snapshot. After that, you should see your snapshot appear on the list below, along with its size (note that this is not exactly how much of the memory is being allocated by JS context, but we just need to tell how much snapshot size changes while we use the app).

Safari Web Inspector attached to running React Native app

4. Comparing memory snapshots

Now it is time to perform your “memory neutral” workflow. After you are done, you can take another snapshot and compare their sizes:

“before” and “after” memory snapshots

In this case, the second snapshot is bigger. As we didn’t expect the memory usage to increase (we have performed “memory neutral workflow”), it is very likely that the app is leaking memory.

Safari allows you not only to inspect individual snapshots (i.e., see a list of allocated objects) but can also help with comparing two snapshots. Snapshot comparison tool lists only the objects that are present in the second snapshot but not in the first one. So, in theory, if some objects are leaking, they should be present on that list.

In order to open snapshot comparison view, click the button that is next to the snapshot taking the tool (1), then select “before” (2) and “after” (3) snapshots from the list:

5. How to analyze snapshot diff

In order to explain this part better, we prepared a sample React Native app. The app has a single navigator with Home screen and a Details screen that can be opened directly from Home. Details screen uses AppState module to register a listener that doesn’t get removed. The Details screen retains a large string, so it is easier to identify the leak. In practice, components in your app will often retain react subtrees which in many cases sum up to a large amount of memory.

We start by taking snapshot on the home screen. Next, we open and close Details screen several times and take the snapshot from the home screen again. Below are the result of comparing these two snapshots:

Result of comparing “before” and “after” snapshots

Once you have snapshot view in front of you, the thing you want to be looking first is the Retained Size column. As opposed to Self Size, the Retained Size column gives you the size of the object (self-size) plus the size of all the other objects that are retained directly or indirectly from that object. Often, the culprit is the object that with the highest retained size.

In our case, we can notice that DetailsScreen is one of the objects that retains the largest amount of memory. Also, since we’ve closed details screen already, we would not expect that object to be still in memory. We can now suspect that DetailsScreen is leaking.

Note, that snapshot comparison does not know which objects have leaked and which haven’t — it only shows you a diff between two snapshots. When you perform actions changes of the react component tree, the framework may create and replace objects that belong to some of its internal data structures. All those objects will be listed on a snapshot diff, but should not be considered as leaked.

6. Locating a source of the leak

In the previous section, we found that DetailsScreen object is leaking — it was listed in memory snapshot while the screen has already been closed and unmounted. Now we need to figure out why it has not been garbage collected.

For that part, we need to know what is the retain path for the object. Retain path starts at the root context, contains a list of objects and ends at an object we selected — each object on the path (except the root context) is retained by the previous one. For a single object, there can be many (or even, in case of cyclic references, an infinite number of) retain paths. Safari Web Inspector shows only the shortest path, that is the one that contains the lowest number of objects.

To see the retain path you need to hover an object with your cursor:

Shortest retain path for DetailsScreen object

At the bottom of the retain path we see the object that directly references DetailsScreen.

We can now go and inspect some of DetailsScreen parent nodes along the retain path. One way to do that is to click on object IDs from the path (e.g., “@48680”) and that would print the object to the console. In case of function instances, we can also click the right arrow icon — it will navigate us to the function source code.

When we select to print handler object (the 3rd from the last on the retain path) this is what will show up in the console:

One of the parent objects printed out in the console

Note that the printed code is what our JS bundle contains. As it has been transpiled by babel, it will differ from what we have in our JS files. Fortunately, it is often enough to give us some idea of what part of our code it corresponds to:

Note that not all of the objects on the retain path are leaking. In the process of identifying the source of the leak, we need to find the first object on the path (starting from the top) that we don’t expect to be present in memory (a first leaked object).

Remember that the retain path you inspect is the shortest path that Web Inspector could find. It certainly doesn’t mean that it is the only path. If you ended up fixing one path (e.g., in our case by removing listener in componentWillUnmount), remember to recheck your app, as there could be other paths that make objects to be kept in memory.

What about Android?

Connecting Safari Web Inspector to JSC (JavaScriptCore) instance running on Android is currently not supported. We are currently working at Software Mansion on bringing a more up to date version of JSC engine to React Native Android apps, at the same time making progress to enable remote debugging there too. Check out our JSC buildscripts repo on Github or follow us on Twitter to get updates about that project. For now, on Android, the only option is to rely on remote debugging using Chrome.

Using Chrome DevTools

Instead of using Safari Web Inspector, a very similar process can be performed in Chrome with your app running in “Remote Debug” mode. If you prefer Chrome DevTools over Safari, stay tuned for our next blog post in that topic where we will describe in details how to fix memory leaks in React Native apps using Chrome.

Secret sauce – MUST READ!!!

From our experience of tracking memory leaks, we have identified a couple of React or React Native specific quirks that were interfering with this process. To avoid chasing retain path red herrings, we listed a few rules to follow when inspecting memory snapshots:

  1. Use prod builds of your React Native bundle. In dev mode, some React Native core modules will retain objects only to be able to provide more descriptive warning messages. One example is RN’s event pool that retains touch events dispatched to components. In dev mode events returned to the pool will retain the reference to their previous target component. This does not happen in prod builds, so by using prod you should be safe from this type of issues
  2. We noticed that when components get unmounted in some cases, they stay retained in React’s internal data structures (in FiberNode under the nextEffect field to be precise). We haven’t had enough time to dive to the bottom of this issue but noticed that removed component stay referenced like that only temporarily. The easiest way that we found to get them cleared was to trigger setState for any component in the tree. Will update here on progress of investigating this issue, but until then we recommend that you add a simple button with a counter that increments using setState that you could click prior to taking a snapshot.
  3. Watch out for console.log statements. When you pass an object to console.log and have Web Inspector connected, the console will retain the object so you can later expand and inspect it.
  4. The above also applies to printing objects from memory snapshot list or from the shortest path popup. When you print an object this way, it will be retained forever (some sources claim that the reference will be removed when you clear console output but it didn’t work for me). So if you want to perform another snapshot-taking session after you printed stuff to the console, we recommend you reload the app and reconnect Web Inspector.
  5. Before taking final snapshot trigger garbage collection. This way make sure that no unretained objects would pollute your snapshot:

Real-life examples

Below you can find two cases of interesting memory leaks in public open-source repos. We hope they will give you a better understanding of how the two most common mistakes we listed at the beginning of this article may surface in practice.

  1. The first case is a memory-leak related issue reported on React Native repo: https://github.com/facebook/react-native/issues/19826. If you read through the comments, you will see that the issue turned out not to be a problem with React Native. An app provided to help reproduce the issue turned out to be leaking components via Animated loop that has been started in componentDidMount and never detached. An interesting thing to note is that animations use a very similar mechanism to what setInterval does under the hood. So forgetting to stop animation has the same result as forgetting to call clearInterval on registered callback.
  2. The second case is a closure scope leak found in the react-navigation repo (thanks Eric Vicenti for fixing it). The commit that fixes the leak provides a pretty good explanation about the root cause of the issue. In short, in getDerivedStateFromProps lifecycle method, which should calculate the next state, retained a reference to the previous state via closure scope, creating a potentially infinite linked list of states that would grow each time the method was called. Thankfully, the issue’s impact wasn’t very significant as it could’ve occurred only under quite rare circumstances.

I want more!

There are a lot of interesting topics in the area of memory management in React Native. We have just scratched the surface here. Stay tuned for more blog posts that coming and follow us on Twitter.

Resources

🙌 thanks to Marcin Skotniczny and Stanisław Chmiela

--

--

Director of engineering at Software Mansion. React Native contributor and former member of React Native team at Facebook.