Improving React Runtime Performance

Make it a habit to measure your performance. There may be a lot of easy but impactful fixes that you need to do;

Luciano Holanda Gomes
Blog Técnico QuintoAndar
8 min readJul 5, 2018

--

A highway because, you know, like, fast things and such

At QuintoAndar we have been working hard on building our Single Page Application, Progressive Web App as a way to improve the experience of our users. As a part of this process, one of the main concerns was to have our web application feel native, providing a smooth and fast 🚀 experience. If you ever built a large javascript application, then you already know how much of a challenge this is.

During that process, we learned a lot about how to maintain good productivity while avoiding most pitfalls that can deteriorate performance. However, if you want to develop fast, some things will break and one thing that has a knack for breaking is performance.

With that in mind, it has become a habit for us to profile our pages regularly, fix any performance errors found and profile again while also keeping constant tabs on our Lighthouse score. In this post, you'll learn some of the techniques we use for profiling, the lessons we learned on how to fix this issues and the impact that had on the experience at the website through some real-world examples of what we've done.

A Primer on Runtime Performance

A lot of the performance issues we find in the application are component-specific and have very particular characteristics, but some kinds of optimizations tend to be more frequent. We search for these first when measuring and looking for ways to improve containers or components across our apps. Those are:

  1. Deferring rendering: We try to defer the renders of heavy &/or components that require user input to be visible (search bars, autocompletes, menus, etc.).
  2. Preventing unnecessary reconciliation: This category already encompasses many different kinds of problems, but finding them is usually done through the same process, i.e. highlighting component updates &/or finding unnecessary updates in the User Timing panel (we'll get to that)
  3. Preventing Components from overly complicating the render tree: Some components create very long lists, which we can solve through virtualization using, for example, react-infinite or react-virtualized. Also, if you, like us, use a styling lib that can generate classes based on props, e.g. styled-components, some components can create too many classes and this can become a problem for Reflows/Layouts and repaints, especially if you do it based on user input like dragging a map or scrolling through the dom.

Profiling

All profiles found here will follow the process below:

  1. Setup chrome remote debugging on a mobile device. Even better if we use the least performant device we wish to support + throttle network to at least the Fast 3G setting;
  2. Forward the port used by the React application;
  3. Run the application in development > Open a tab on the browser to your application > Open Chrome Dev Tools > Performance > Inspect mobile device;
  4. To profile initial mounting: Hit Start Profiling and reload page button;
  5. To profile interactions with the page, after page load, hit the Record ⚫️ button; Interact with the page and hit the Stop 🔴 button;
  6. Interpret the results

Landing Page (quintoandar.com.br)

We ran the profiler in the web page to seek possible improvements, and some things jumped to sight. Like the <SearchBar/> component waiting on the render of the <AutoComplete/> which is an initially non-visible component:

104.5 ms spent mounting the SearchBar Component and over half is the AutoComplete

Deferring Render

Components that are not initially visible can be loaded incrementally to prioritize the content that is.

To defer render we could use two different approaches:

  1. window.requestAnimationFrame
  2. Predefined delay (using setTimeout or setInterval)

They're very similar and simple to implement. However, we usually prefer requestAnimationFrame, that way we guarantee that the state update of the component happens only at the start of a frame. Here's an implementation that worked for us:

If other pages also contain this components or if we can tolerate a slightly longer wait after the initial render, we can go one step further and import them dynamically. To better understand that and how to do it, you can check webpack's documentation, which has a detailed Code Splitting section.

One other thing to note is that if you, like us, have a Server Side Rendered react application, whenever you defer the component render by using componentDidMount, it won't be rendered on the server, and we would have to go some steps further if we'd like to preload this chunk.

After deferring the SearchBar render we could lower the mounting time in half:

Cutting the time of mounting in half without any impact to the end user

Searching further in the timeline we can also see an update in the SearchBar that doesn't make sense since no props changed on this component:

Useless update on SearchBar triggered by a state change

This was triggered because the component state was being used to track whether the Google API was loaded or not, hence there was a setState({ isGoogleLoaded: true }) being called for a value that has no impact in the render function and was very easy to remove, just by changing it to an instance variable, i.e.: this.isGoogleLoaded = true

Besides that, we could also find some other updates that were triggered and shouldn't be on different components, like these, for example:

Updates that should be triggered causing a longer update cycle than necessary

There are quite a few updates triggered in this components, and their props are not changing, only their parent's. Usually happens due to the common misconception that components which are Pure functions are also PureComponents. Hopefully, recompose provides a nice HOC, pure, that can make the component pure without us having to turn it into a class. Most of these updates were solvable that way, but some were more tricky.

There are components which receive objects as props and use only one of the values inside the object; this means that, if some other value inside the object changes, they should not be updated. That was the case for the WorkWithUs component; it uses an object that has a structure similar to:

# WorkWithUs propslocation: { 
search: 'value'
...
}

And only uses the search property. This prop is injected by a HOC that is part of an external lib and used in a lot of different places. So what we did was flatten this location prop and restrict the update to only the search making it a pure component:

import mapProps from 'recompose/mapProps';
import pure from 'recompose/pure';
import compose from 'recompose/compose';
const WorkWithUs = compose(
hocThatInjectsProp,
mapProps(({ location: { search } }) => ({ search })),
pure,
)(WorkWithUsComponent);

After dropping some pure at some components, we managed to cut that update cycle to a portion of the time it occupied:

Time went from 132.80 ms to 82.80ms & reduced the work done in this cycle by avoiding updates

Search Page (quintoandar.com.br/alugar/imovel)

Moving to the search page, this is a page with a lot more user interaction. We have two different versions of this page the Desktop version and the mobile version:

  1. Mobile
Mobile Search Page

2. Desktop:

Desktop Search Page

These screens reutilize the HouseList and Map components. So the first thing we did, since we have a possibly very long list, was to virtualize the list, and to help us with that we decided to use the react-infinite library to do this virtualization.

But for some reason, after scrolling a bit in the list, both on the desktop and mobile pages, we saw the input latency increase drastically, both for scrolling and dragging the map, which are the two primary user interactions with this page. Checking the timeline and the updates, we couldn't see anything abnormal or any updates that shouldn't be happening upon scrolling, so we went to another section at the timeline, the Main which shows the activity on the Main thread:

Map Drag-Response Lifecycle

This timeline shows us something very unusual: a long Recalculate Style time, even considering that this is a very style heavy, we shouldn't see 128.8ms of style calculation time within one Drag-Response. So we went to the code to try and find what was causing this.

As it turns out, the images are within an <AspectRatioContainer/> which is a container that sets their size appropriately so that they conform to the AspectRatio that we expect for images and there were two problems with this container, as we can see in the code below:

// AspectRatio.js Beforefunction AspectRatioContainer(props) {
const Container = styled.div`
${(props) => `background: url(${props.loadingSrc}) 0 0/100% 100% no-repeat;`}
...
`;
return (
<Container aspectRatio={props.aspectRatio} className={props.className} innerRef={props.innerRef}>
{props.children}
</Container>
);
}
  1. 1 class per render, because the component calls the styled() factory inside the render method
  2. The class changes on each different background URL. So, for each different image, we used as a background, styled components was creating a new class, clogging our CSSOM tree with A LOT of unused classes.

So this was also an easy fix, we just had to use the styled attrs to set the value of the background and remove the definition of the styled components from the render function, like so:

const Container = styled.div.attrs({
style: ({ loadingSrc }) => ({
background: `url(${loadingSrc}) 0 0/100% 100% no-repeat`,
}),
})`
...
`;
function AspectRatioContainer(props) {
return (
<Container aspectRatio={props.aspectRatio} className={props.className} innerRef={props.innerRef}>
{props.children}
</Container>
);
}

With this small change, we were able to get way faster Drag-Response cycles, and halved the recalculate style portion of this update:

Faster RecalculateStyle by preventing superfluous class creation

These optimizations are some examples from an ongoing work that we do while also making sure that we keep up with new features and releases. There's a lot we already know that we need to do and a lot will come as we release new features. If you find something that we could be doing better, let us know and comment below!

Summing Up

  1. Make it a habit to measure your performance. Every new feature can deteriorate the performance of the whole page; Measure, optimize and measure again;
  2. Pay special attention to user interaction, like scrolling, dragging, clicking and typing;
  3. Stateless Components are not the same as React.PureComponent. Plus, we can use recompose to convert these components to PureComponents easily and also to do some prop juggling; But, most importantly, always avoid useless updates;
  4. Beware of the size of your render tree, clogging the DOM &/or the CSSOM can have a noticeable negative impact.

If you'd like challenges like this and would like to contribute to making rent cool and easy, we are always looking for talented, eager to learn people to join us in São Paulo.

OBS.: We already do a lot of the good practices for performance and scalability, like the ones Twitter did, like following asset caching best practices, having a CDN to minimize round trips, etc. This kind of optimization should come first and be your primary focus initially (:

--

--

Luciano Holanda Gomes
Blog Técnico QuintoAndar

Software Engineer @ QuintoAndar. Proud "owner" of 3 beautiful cats 🐈 Passionate about Web Development, Performance and building awesome user experiences