Journey of Improving React App Performance by 10x

Mayank Sharma
Technogise
Published in
6 min readMay 24, 2021

--

Chrome Aw, Snap Error with code 5.
Famous Chrome Error

Ever got this “Aw! Snap” on your application? Tried to solve it? Did you just google it and find nothing but a “chrome issue” article? Generally, a simple page refresh would make your application run again.

A year back in Technogise, I got an opportunity to work on a front-end heavy ReactJS application with lots of features. We inherited this codebase, made major improvements to it and started adding more interesting features to the app.

However, we often got complaints from our QA and end-users that they see the above screen. After analysing, we found that this was happening because of the application’s 1.5GB memory footprint 🙈

In this article, I will talk about our journey of improving the memory footprint of this app from ~1.5GB to ~150Mbs, thereby improving the performance of the application by 10x, and ensuring no more Aw Snaps !!! 😌

Note — If you don’t know how React works internally please head to the reconciliation process of React.

Finding performance bottlenecks

There are many tools and libraries available to find out the real bottlenecks in your application. We tried a lot of them and for us, the three below were very useful:

1. Profiling Components using Chrome Browser Extension

The flamegraph from this tool is very informative, but it’s time-consuming. It helped us understand the time taken by each component to render. The colour-coded bars showed us at a glance which components took the longest to render. The actual duration can be seen on the bar (if there is room for the text) or can be seen by hovering over the bar. A ranked chart helped us as it is ordered by the duration of render time.

Profiler

2. Memory Snapshots from Firefox

This tool gives us a snapshot of the current tab’s heap memory. Its treemap view provides a visual representation of the following things:

  1. Objects: JavaScript and DOM objects, such as Function, Object, or Array, and DOM types like Window and HTMLDivElement.
  2. Scripts: JavaScript sources loaded by the page.
  3. Strings
  4. Other: This includes internal SpiderMonkey objects.

It helped us understand which one is consuming more memory and eventually slowing down our application.

Example Snapshot from Firefox

3. Using the package why-did-you-render

This is an npm package from Welldone Software that notifies you about the avoidable re-renders. It is easy to set up and gives out detailed information about why and when a certain component re-renders. It supported us to pinpoint the improvements.

why-did-you-render in action

Analysing data points and fixing improvements was looking difficult because the flamegraph looked like an infinite staircase. Going through each page’s flamegraph added more and more components to the list of improvements.

So how did we improve performance?

We came to know that all of these are happening because of the unnecessary re-rendering of components. A component re-rendered like 50 times just by doing a page refresh and no interaction on one page! 😮

So, we discussed and came up with a strategy to take baby steps, improve one thing at a time. We looked at the smallest of the components and asked ourselves that if the data is not changing, then why does this component re-render itself? We started seeing the patterns and came up with the resolutions below:

1. Remove all Inline Functions

An inline function is a function that is defined and passed down inside the render method of a React component.

Example of in-line function

Our code was filled with inline functions. Inline functions have 2 big problems:

  1. It will always trigger a re-render of the component even if there is no change in the props.
  2. It increases the memory footprint of the app. (Refer: Function spaces in Memory snapshot of Firefox)

This is primarily because method passing is always “pass by reference”, so it will create a new function and change its reference for every render cycle. This occurs even if you have used PureComponent or React.memo().

Solution: Move all inline functions outside the render() such that it does not get redefined on every render cycle.

This decreased the memory footprint from 1.5Gb to 800Mb 😄

Improved in-line function

2. Avoid modifying the state if there is no change in state from your Redux store

Commonly we store API response in the Redux store. Assume that we call that API again and get the same data, should we update the Redux store? The short answer is No. If you update the data then components that are using this will render again as the reference to that data is changed.

As our code was inherited, there was a hack added in components to handle this. The hack was: “JSON.stringify(prevProps.data) !== JSON.stringify(this.props.data)” 🙈. We got this hint through Firefox Memory Snapshot, as the string was taking more memory on a few pages.

Solution: Use an effective comparison before updating the state or Redux store. We found 2 good packages — deep-equal and fast-deep-equal. The choice is yours!

This decreased the memory footprint from 800Mb to 500Mb. 😄

3. Conditional rendering of the components

Commonly, we write down components that get rendered when clicked or any other event e.g. Modals or Dropdowns. Below is an example of a modal:

A common way of writing Modals

We identified that a lot of these components are rendered when they are not needed. These were big components with many child components and API calls associated with them.

Solution: We avoided rendering these components until they are needed i.e. Conditional Rendering.

This decreased the memory footprint from 500Mb to 150Mb. 😄

Improving above example as below:

Improved way of writing Modals

4. Remove unnecessary awaits and use Promise.all() wherever applicable

Coming from Java, Python, Golang background (or any language that has sequential execution), we use awaits at most places. However, this could have a huge performance impact when doing any long-running executions like calling API to save or update data.
Commonly, we call APIs on application load to get the initial data. Imagine that your web application needs a good amount of data from 3–5 API calls, like in the example below. The get methods in the below examples are associated with an API call.

const userSubscription = getUserSubscription();
const userDetails = getUserDetails();
const userNotifications = getUserNotifications();

Solution: We identified that most of the API calls can be made to execute in parallel. So, we used Promise.all() to help us send all these API calls parallelly.

This decreased the initial page load time and other pages by 30%.

const [
userSubscription,
userDetails,
userNotifications
] = await Promise.all([
getUserSubscription(),
getUserDetails(),
getUserNotifications()
]);

Even further improvements can be done in a ReactJS project. I will list those in a different article.

Summary

To improve the performance of your React app,

  1. Avoid inline functions as much as possible. If your application is small, it will not affect you much but once you develop more and more features, this will bite you for sure.
  2. Remember that Immutability is the key to avoid unnecessary re-renders.
  3. Always render hidden components like Modals and Dropdowns conditionally. These components are not visible until triggered, but are affecting your application’s overall performance.
  4. Always call multiple APIs parallelly. Sequential calls affect load time.

And after an effort of three weeks (including testing), we finally deployed our code to production. Till now we haven’t got an “Aw! Snap”. The app is much snappier… 😄

--

--