A Deep Dive into React Perf Debugging

This is the second half of a 2-part series on performance engineering in React. In Part 1, we took a look at how to use React's Perf tools, common React rendering bottlenecks, and a few tips for debugging. Take a look if you haven't already!

In Part 2, we'll now dive deeper into debugging workflows - given all these ideas, how do they manifest in practice? We'll walk through a few real-life-inspired examples and use Chrome devtools to diagnose and fix performance. (And if you end up with any suggestions or additions, let me know!)

We'll refer to the sample code below - you can see that it renders a simple todo list in React. In the JS fiddle snippets that follow, you can click "Result" for an interactive version, complete with performance repros. We'll post updated JS fiddles as we go.

Case Study #1: TodoList

Let's start from the TodoList above. Try typing fairly quickly into the non-optimized code, and you'll notice how slow it is.

Let's start the Timeline profiler in Chrome's dev tools, which gives a detailed profile of what the browser is doing: handling user events, running JS, rendering and painting. Type in a single character in the input, and then stop the timeline profiler. The slowness won’t be as apparent, since we typed just a single character, but it is the fastest way to produce the minimum amount of information needed for profiling.

Note the long bar for Event (textInput) and that Aggregated Time shows 121.10ms in Scripting (Children). The timeline profiler reveals that the slowness is a scripting issue and not a style / recalculate issue.

So we dive into scripting. Switch to the Profiles tab - while Timeline gives you an overview across the browser (and supports JS Profile), Profiles lets us do a deeper dive into JS-land and has some different visualization tools. Recording another profile indicates that the slowness isn't from our application code:

Looking at the profile by Heavy (Bottom Up), descending sort by Total indicates that most of the time is spent in React's batchedUpdates call, which is a pretty clear hint that it's in React-land. In contrast, Self measures time spent in the function, excluding time in child functions - sort by Self to see if there are any specific expensive functions. It looks like there's no obvious bottleneck in a user-land function, so let's try React's Perf tool instead.

To generate a measurement profile for our slow action, in the console, we call React.addons.Perf.start(), perform the slow action by typing a character, and then finish by calling React.addons.Perf.stop(). We can then see time spent on unnecessary renders with React.addons.Perf.printWasted():

The first item shows TodoItem was rendered by Todos; however Perf.printWasted() found that there we could have saved 100ms if we avoided re-building the render tree. This looks like a prime candidate for optimization.

To diagnose why TodoItem wastes so much time, we've created a custom mixin, cleverly named WhyDidYouUpdateMixin. It hooks into a component and logs information about which updates happened and why. Here's the code; feel free to adapt it to your own needs

Once we add this mixin to TodoItem, we can see what's going on:

Aha! We see that tags is the same before and after - the mixin says it's avoidable if two objects are deeply equal but not strictly equal. On the other hand, it has a harder time figuring out if two functions are equal, since Function.bind produces a new function even with the same bound args. These are useful clues, though - we look back at how we're passing in tags and deleteItem, and it looks like we're passing in new values each time we construct a TodoItem.

If we instead pass in the unbound function to the TodoItem, and we store the tags as a constant, we avoid the issue:

WhyDidYouUpdateMixin now shows that the prevProps is shallow equal to the new props. We can use PureRenderMixin, which will skip updates if props (and state) are shallow equal.

When we run the profiler again, we see that it only takes about 35ms (4x faster than before):

This is better, but still not ideal. Typing in an input should not result in this much overhead. It turns out we're still doing O(number of items in list) work. We've merely reduced the constant, but we still need to do a shallow compare for each item.

At this point, you might decide that 1000 items in a todo list is already an extreme case, and 30ms is reasonable for your application. If you're expecting to support a few thousand children, though, this isn't quite yet the ideal 60fps (16ms per frame - any slower and you get noticeable lag).

Breaking the component into multiple components is a reasonable next step (arguably a valid first step as well). We observe that the Todos component really consists of two disjoint subcomponents, an AddTaskForm component containing the input and a button, and a TodoItems component containing the list of items.

Each of these refactors provides substantial performance gains:

  • If we create a TodoItems component that uses PureRenderMixin, then we avoid doing the O(n) work since it would skip re-rendering each item, as prevProps.items === this.props.items.
  • If we create an AddTaskForm component, the state of what text has been entered can now live there. When that text changes, the Todos component no longer re-renders (avoiding the O(n) render).

Combined, we end up at 10ms per keypress!

Case Study #2:

Scenario: We want to conditionally render a warning if the user has too many tasks (> 3000), and we also want to style the todo items so that every other item has a background color.

Implementation:

  • We have a similar todo list example, with TodoItems implemented - for this example we'll store the text of the input on the top-level component state.
  • We create a TaskWarning component that chooses to render the message based on the number of tasks. To encapsulate the logic within the component, we have it return null if it shouldn't render.
  • We add some CSS to style div:nth-child(even) with a gray background.

Observation: Type quickly in the input, the page lags quite a bit (with no more than 3000 tasks). If we first add 1 more item to our todo list (> 3000 tasks), the lag goes away when button mashing. Surprisingly, adding more tasks fixes the problem!

Debugging: The timeline profile shows something very interesting:

For some reason, typing a single character causes a large Recalculate style that takes 30ms (which is why if we type faster than 30ms/character, we observe jank).

Take a look at the First invalidated section towards the bottom of the above image. This is indicating that Danger.dangerouslyReplaceNodeWithMarkup is causing layout invalidation, which then leads to style recalculation. Here's react-with-addons.js:2301:

oldChild.parentNode.replaceChild(newChild, oldChild);

For some reason, React is replacing a DOM node with an entirely new DOM node! Recall that DOM manipulations can be expensive. Using Perf.printDOM(), we can see what DOM manipulations React is performing:

The update attributes reflect typing abc into the input while TaskWarning is not visible. However, the replace indicates that React is deciding to touch the DOM for the TaskWarning component, even though it seems like it should have an identical virtual DOM.

As it turns out, React (<= v0.13) uses a noscript tag to render "no component" but incorrectly treats two noscript tags as not equal: the noscript ends up needlessly replaced by another noscript. Furthermore, recall we styled every other div with a gray background. Because of the CSS, an individual render of any of the 3000 item nodes is dependent on the sibling nodes before it. Each time the noscript tag is replaced, the subsequent DOM nodes have their styles recalculated.

To fix this issue, we can either:

  • Have TaskWarning return an empty div
  • Move the TaskWarning component inside a div as it won't be able to affect CSS selectors of nodes that follow it.
  • Upgrade React :-)

But that's beside the point. The key takeaway here is that we were able to diagnose this ourselves, just from the timeline profiler!

Conclusion

I hope this was useful in showing how React performance issues can appear in dev tools - a combination of Timeline, Profiles, and React's Perf tools goes a long way.

Todo lists with thousands of items and arbitrary coloring may seem contrived, but there were very similar problems we encountered when rendering large documents and spreadsheets, when building an electronic lab notebook. And yes, we're still growing our team - if building complex React apps excites you, get in touch.

Any suggestions or comments? Let me know! You can reach me via saif at benchling.com.

Discuss on Hacker News