How we 3x’d our React Webapp Performance

We recently improved performance by an average of 3x in a key part of our React web application. This post covers the techniques we used and the lessons we learnt to avoid these issues in the future.

Published on


Do not index
Do not index
Status
Done
At Maze we want to empower User Researchers, Product Marketers, Product Designers and Product Mangers in their journey to product discovery. Our main product helps to gather relevant user insights on what to act on in order to help create beautiful experiences.
Our engineering team is committed to help with that and improve Maze every day. Our business has grown rapidly over the last year resulting not only in more clients signing up but more intense usage of Maze from our power users. It has been very validating to see our product become such a large part our of clients workflows but this has naturally pushed our application, resulting in some clients reporting performance degradation in some parts of the app.
This post is going to look at the challenges we faced, how we improved performance by an average of 3x in our React web application and the lessons we learnt to avoid these issues in the future. Before we get into the thick of it, a little context on our web application will be useful to understand the problem.

Understanding the Maze experience

There are 4 important steps between creating a Maze and getting useful insights:
  1. Build phase - Creating a new maze (either from scratch or using a template) and adding different blocks
  1. Share phase - Sharing the maze with testers after making the maze live
  1. Results phase - Keeping an eye on the detailed results given by the testers and aggregated statistics that help our clients make informed decisions
  1. Report phase - Creating a nicely formatted report with aggregated data and sharing it with your stakeholders.
Taking into account the requests from our customers the main focus for our performance efforts is on the build, results and reports phases and its pages.

Customer feedback / Listening to our customers

The two major complaints identified are:
  1. The experience provided in the builder is being degraded over time. Customers described that applying changes to individual blocks is slower than before, especially in large mazes.
  1. Opening the results or reports pages on a maze with a Mission or/and Card Sorting blocks, along with a large number of testers takes a considerable amount of time. We are also rolling out some exciting new features that will dramatically increase the number of responses mazes receive so we want to ensure these pages are ready.
We decided to invest on improving the experience and one of our Pods at Maze was responsible for starting analysing the current status and what would be the short term quick-wins and long term changes we would like to apply to provide a best-in-class UX.

Our approach

Based on client feedback, we knew where in the app the issues were happening so we started with a simple approach to understand the underlying cause of the performance issues. Taking into account the tech stack we use at Maze and our React based front-end we ended up profiling the performance of certain actions in this area of the UI.

Practical example: The prop-drilling builder

There are a number of different actions that a user normally takes when in the builder:
  • Creating a new block
  • Editing a block
  • Reordering a block
  • Deleting a block
  • etc
For each of these actions, we used the React Dev Tools profiler to record the action and investigate what happens.
notion image
An example of editing a block title and using the React Dev Tools profiler to analyse the corresponding flamegraph.
 
This flamegraph chart tells us a few useful things:
  1. Which components are rendering when we perform the action
  1. How long the components take to render
  1. How many times the component renders
notion image
💡
We also throttled the CPU performance making it 4x slower. This helps to emulate users with slower machines, as well as making the performance issues more pronounced (and therefore hopefully easier to spot)
notion image
 
These 3 pieces of information give us lots of insights when we think about them in the context of the action.
Which components rendered?
When we update a block title, the only components that should render are the block title field and the places that block title is used. So if we see unrelated components rendering, we know that these components are a good place to focus to reduce the number of components rendering (perhaps they are subscribed to state they don’t need?).
How long did the components take to render?
If certain components are taking a very long time to render, this may highlight areas where some app logic is more intensive than it needs to be. Or the children further down the tree need improving.
How many times did the components render?
Finally, the amount of re-renders that happens for a given action helps us identify how efficient our state management and structure is. If we update the title of a block but this change has a cascading effect which results in many different renders happening one after another, it is a sure sign that the title change in state has some knock on effects that perhaps it shouldn’t.
 
💡
We also used why-did-you-render, an NPM packages that we highly recommend to get more detail on the specifics of why a component or hook rendered.

What were the issues?

After immersing ourselves in the React profiler, we identified some key components that were rendering too much and taking to long to render.
Below you can see the main sections in our builder. The red box which contains everything is our DraftMaze component. Within the DraftMaze we have three key panels (shown as blue boxes):
  • Left: the BuilderBlockList
  • Middle: the BlockForm
  • Right: our BlockPreview
notion image
 
Our biggest issues in order of importance:
  1. We were storing blocks as an array of objects and passing that array down the tree via props → therefore every change to a block caused everything to re-render.
  1. We had too much state stored in DraftMaze which was then passed down via props → again any changes to this state caused re-renders all the way down the tree.
 

Fixing the issues:

Move global state out of the components
We had far to much state stored in the DraftMaze, so we starting but moving all global state into redux. Given redux uses context behind the scenes this laid the foundations for us to be able to access and manipulate that state only in the components where it is needed (rather than prop drilling and causing re-renders). We created memoized selectors and more precise actions to support this. We also took the opportunity to improve the shape of block state to make it easier to work with.
Memoizing derived state
We use a lot of derived state in the builder. For example, we might want a list of all the hidden blocks. Previously, we used Array.filter() to filter all blocks where hidden === true. This results in a new array being returned each time it is run. This new array will be interpreted by React as a new prop and will ultimately cause a re-render. To avoid this, we memoized derived state either manually using useMemo or through re-select, a library specifically for creating memoized selector functions.
Only selecting the state we needed
The final piece to the puzzle was only selecting from state the precise state that we needed. If a <Title /> only needs access the the title property of a block, we should only get the title property - there is no need to select the whole block and expose the component to irrelevant state changes. By only selecting the state that we need, changes to that state will only cause re-renders in the components where it is relevant (and therefore reduce the number of renders that happen)
These patterns may seem trivial and standard in today’s world of working with React but the reality of building software is that you have to work with code that was written in different eras and with a different set of goals. Whilst Maze’s codebase is more modern than most, tech debt naturally still exists and some legacy patterns are still being refactored into modern principles.

Measuring success

Making the above changes in an incremental and error free manner took quite some time but the pay off was worth it. We used the same profiling techniques as before - calculating the time and number of renders for certain user actions - and then compared the results to our previous scores. The results varied between each action but the performance was at worst 1.2x better and at best 5.7x better (with an average improvement of about 3x).
Even when throttling CPU by 4x on very large mazes, the results are vastly improved and the key clients that initially flagged the issue have since fed back how much improved it feels.
Action
Performance Improvement
Selecting a block
4.8x
Updating a block
3.3x
Creating a block
1.5x
Reordering a block
5.7x
Duplicating a block
2.8x
Hiding a block
2.1x
Unhiding a block
2.0x
Duplicating and hiding a block
2.3x
Deleting a block
1.2x

What did we learn?

This project really highlighted some key principles that will guide how we build React apps in the future.
1. React component trees tend to get deeper and more complex over time
The future complexity of your application will be built upon the foundations that you lay early on, so start with good standards. If you find yourself prop drilling state to multiple children, ask yourself whether your should move that state to a global store. The later you leave this decision the harder it becomes to unwind (and the more likely you and your colleagues will put it off until you “get a moment”). DraftMaze started out as a simple component when our product was much smaller. Rather than tidying it up, we added to it, kicking the can down the road until we had to make the change.
2. Typescript and good test coverage allows you to move faster
When I first started learning Typescript, I hated it. It felt verbose, unnecessary and ugly. However, when it comes to refactoring a component to remove unused state, it is a total life saver. All the issues/bugs we had came from older JS components where we didn’t have type safety.
The same applies for good test coverage. We have excellent test coverage in the builder and this allowed us to push are changes to CI and be confident that if it was all green we could merge it rather than needing to manually QA all the intricacies of such an integral part of our application.
3. Be precise in how you store, access and manipulate global state
The more deeply nested your state is, the more care you need to take to ensure that referential changes don’t end up causing unnecessary renders in your application. Deeply nested, complex global state also encourages developers to pass around composite data types rather than accessing the properties they need. Your life will be far easier if you pick the right state shape, use only the state you need where it is needed and be precise about how you manipulate it.
 
We’ve been learning a ton on performance and we’re keen to share our findings with you! We just set up our engineering blog and this is our very first article, stay tuned for more!
Interested about the Maze product? Check out more at maze.co
 
 

Written by

Henry Black
Henry Black

Senior Full Stack Engineer @ Maze

Written by

Daniela Matos de Carvalho
Daniela Matos de Carvalho

Staff Full Stack Engineer @ Maze