Achieving Two Goals: Performance Optimization and Cost Reduction

Multi-select
optimization
react
Parent item
Sub-item
notion image
 

Overview

notion image
Our company's service provides personalized, precision nutrition supplements based on users' health checkup data, current medications, and daily conditions.
 
Recently, the number of selectable daily conditions has significantly increased. Additionally, the concept of severity levels (ranging from 1 to 3) was introduced for each condition, resulting in an exponential growth in the total combinations.
This caused severe performance issues when entering the "Daily Conditions" page, particularly on low-spec tablets, such as the Samsung A7 Lite that we choose, where significant lagging occurred.
 
Considering that more condition types are expected to be added in the future, further performance degradation was inevitable.
 
⚠️
The company needed to determine whether to address this issue through hardware upgrades or software optimization.
Replacing the tablets would require recalling all deployed units, increasing production costs and reducing profit margins.
 
Therefore, software optimization was deemed essential unless absolutely unavoidable.
 

Problems

The UI response time is very slow, causing a laggy and unresponsive experience.
This leads to frustration and touch errors, significantly affecting the overall user experience. Many users have provided feedback highlighting the slowness and dissatisfaction.
 

Optimization 1: Initial Rendering Load Optimization

Problem

notion image
 
notion image
When the page initially loads, the Daily Conditions Screen is rendered at once.
Each category's position is calculated using onLayout callbacks, which trigger re-rendering of all conditions multiple times (category count * 2).
For instance, with five categories, the all daily conditions components all re-renders five times.
⚠️
The five unnecessary re-renders caused an additional 4760ms of render time.

Root Cause

notion image
  • Each category's position is managed using React.state, which triggers re-renders when updated.
  • Since position syncing between the category bar and the ScrollView relies on a shared state at a higher-level component (the Screen component), the screen itself re-renders along with all its child components unnecessarily.
    • notion image

Solution

notion image
 
Value changes themselves are not a reason to trigger re-renders.
The situations where these values are needed are limited to:
  1. Category Touch Events: When a user selects a category directly.
  1. Scroll Events: When the currently visible category changes during scrolling.
These scenarios can update the UI directly via event handling, without requiring state changes that would re-render the entire component tree.
 
Switching from React.state to React.Ref allows category positions to be stored without triggering re-renders.
 
notion image
This approach eliminates unnecessary renders:
  • Previous Load: 952ms (avg) × 5 unnecessary re-render cycles = 4760ms
  • Optimized Load: 0ms (no additional rendering)
💡
Key Takeaway Avoid overusing React.state.
Only use state when value changes necessitate re-renders.
 

Optimization 2: Preventing Layout Shifts from Animated Components

Problem

Lag occurs during category bar animation, making the application unusable on low-spec devices.

Root Cause

notion image
Design requirements caused header space to shrink during scrolling, affecting the entire layout:
  • Components re-rendered repeatedly due to layout changes.
  • Lack of debouncing exacerbated the issue, with redundant renders during animation transitions.
  • Components lacked proper memoization, leading to unnecessary recalculations.

Solution

notion image
 
Reconstructed the layout to isolate the category bar animation:
  1. Fixed Header Size: The header remains static, with only text visibility toggling based on the scroll state.
  1. Fixed Scroll View Size: The ScrollView's size remains fixed. An empty space was added at the top of its content to allow animation without altering the layout or its dimensions.
  1. Absolute Positioning: The category bar is positioned independently using absolute positioning, enabling its animation within the added space while preventing ripple effects on other components.
By maintaining fixed sizes and isolating the category bar animation, the layout avoids unnecessary recalculations and re-renders, effectively resolving performance issues.
 

Optimization 3: Render Optimization with Memoization

Problem

notion image
Selecting any condition caused the entire condition list to re-render.

Root Cause

  • Lack of memoization for components. Or
  • Inefficient useCallback and useMemo implementations due to captured values changing unnecessarily.

Solution

1. Header Memoization

notion image
Since the header content doesn't change, memoizing the Header component itself is enough to prevent unnecessary re-renders
 
notion image
An interesting point here is that JSX objects are always re-created during rendering, causing React to treat them as new props every time.
Therefore, the JSX computation result itself should be memoized using useMemo.
 
On the other hand, Text is a component that may unmount based on conditions.
Thus, memoizing this component is not appropriate.
When it unmounts and is removed from the render tree, the memoized result is lost, so it won't be reused when re-rendered.
 
notion image
The render time, which used to take 22.9ms per re-render, was reduced to 0ms through memoization.
 

2. Category Bar Rendering

notion image
When selecting each body condition item, categories that do not change are unnecessarily re-rendered every time, even though they don’t need to be.
 
notion image
The memoization strategy is as shown in the image above.
There was concern that memoizing each button might be excessive, but since each button takes 5.7ms to render and only two buttons need change when the category changes:
  • Rendering the other three buttons unnecessarily takes about 17ms more.
  • Determining the memoization with a simple primitive comparison (currentIndex === index) involves tiny overhead.
Thus, it was deemed much more valuable to memoize each individual button as well.
 
notion image
Despite using React.memo, the category bar re-renders every time a body condition is selected.
The categories prop is re-created on every render due to Object.keys(), causing React.memo to treat it as a new value, triggering unnecessary re-renders.
 
notion image
The render time, which used to take 65.8ms per re-render, was reduced to 0ms through memoization.
 
notion image
When a selected button changes, only the previously selected button and the newly selected button's UI should be updated. The other buttons should use the memoized version.
 
notion image
The issue wasn’t just about wrapping the component in React.memo. The main problem was that the function passed as a prop was re-created on every render, leading React to interpret it as a prop change and triggering unnecessary re-renders.
 
To handle functionality based on each category's index, a function was created to process the index. Since the default onPress callback for the button component does not provide the index as a parameter, the function was designed to return a handler for the specified index.
 
However, this approach results in a new function being created on each render, even though the logic remains unchanged and covered with useCallback.
As a result, React perceives the function reference as different and re-renders the component.
 
notion image
I resolved this issue by creating a separate button that accepts the index as prop, and memoize this component.
 
notion image
As a result, when selecting a category, only the changed parts are re-rendered. This optimization improved the render time from the previous 37.8ms to 12.7ms.
 
notion image
The category bar now only re-renders when categoryIndex changes.
Additionally, when the categoryIndex is updated, only the two buttons that are affected by the change are re-rendered.
 

3. Selected Condition List

notion image
The list changed frequently, so the child components were already wrapped with React.memo for memoization. However, they were not actually being memoized.
 
notion image
The value changed by the setter function was included in the dependency array, causing the function to be re-declared every time the value changes, leading to unnecessary re-renders.
Modified the code to reference the current value at the time of function invocation, rather than using a captured value from a closure.
 
notion image
Each "condition" cell takes approximately 27ms to render.
As a result of memoization, the more condition items a user selects for the day, the more noticeable the performance improvement becomes.
For example, with 10 condition items, the rendering time was significantly reduced from 217ms to 29ms thanks to memoization.
 
notion image
Additionally, I replaced the FlatList with a regular ScrollView for rendering the list.
While FlatList is excellent for rendering large numbers of components in a scrollable view by leveraging virtualization for optimization, this use case involved only 5 to 10 components at most, representing the user's selected daily condition.
Given this, I concluded the overhead from computations required for virtualization outweighed its benefits.
 
Using the Profiler, the rendering process showed a reduction in unnecessary hierarchical structures, resulting in a cleaner implementation. Besides, there was a slight improvement in rendering time: 38.4ms28.8ms
 
notion image
Only the newly added or changed body condition are re-rendered, while the rest of the components are reused from memoization, improving performance. Great!
 

4. Condition List Rendering

notion image
This is the key part that requires optimization. There are a total of 45 different daily conditions, and each condition can have a severity change of 1 to 3.
These cells are all re-rendered unnecessarily every time the user interacts.
 
notion image
Through memoization, only the list component and the single cell interacted with by the user are re-rendered. This improved the performance from the previous 940ms to 178ms.
 
As a result of memoization, the rendering time improved significantly, reducing from 986ms to 220ms, and the responsiveness to touches has noticeably increased.
 
However, there is still:
  • a slight delay perceptible to the user, and
  • we must consider the communication and computations involved in real-time interactions with the supplement dispensing engine as well.
Therefore, the current state remains somewhat insufficient.
 
I’ve decided to continue optimizing with the goal of achieving a response time under 100ms.
 

Optimization 4: Design Change for Improved Re-Rendering Time

Problem

notion image
Two list components refer to a single Context State, controlling the internal components of each list.
 
The second list component dynamically adds, removes, or changes subcomponents based on the user's selected body condition. Therefore, it makes sense for the list component to control its sub-elements, and memoizing these sub-components is enough to prevent unnecessary re-renders.
 
However, the first list component always has 45 fixed sub-elements. The only variations are whether they are selected and which severity level (1 to 3) they correspond to.
By understanding this structural characteristic, I believed that most of the responsibilities currently handled by the list component could be delegated to its sub-components, drastically reducing unnecessary computations.
 
notion image
The area marked in the image above represents the part I wanted to improve further.

Root Cause

notion image
Currently, the list component controls the individual cell’s props.
The shared state is centralized and referenced by the list component, which is responsible for calculating and passing props such as isSelected and severity to each cell.
As a result, when the state changes, it is inevitable for the list component itself to re-render in the current structure.

Solution

 
notion image
To solve this, values shared among multiple components should remain centralized, but components requiring re-rendering should update their UI independently.
By doing so, the responsibility of calculating props for rendering the sub-components is shifted from the list component to each sub-component. This leaves the list component with only the task of initial rendering.
 
The solution that immediately came to mind was Recoil.
  • atom and atomFamily allow each component to maintain its own state while still being accessible elsewhere.
  • A selector can define transformations based on changes to the atom.
 
notion image
Since we were already using GraphQL with Apollo Client, I was aware that Apollo’s makeVar could serve as a global state management tool.
As a result, I decided against adding Recoil to the stack and instead leveraged Apollo’s internal functionality to implement features similar to Recoil's atom, atomFamily, and selector like the image above.
  • StateFamily is based on the concept of Recoil's AtomFamily.
  • Each state follows the idea of Recoil's atom.
  • isNothingSelected and selectedList are modeled after Recoil’s selector.
 
notion image
Brilliant! The rendering speed improved dramatically.
To summarize the result of memoization, which impacts every time the user interacts with any ㅇdaily condition cell:
  • The rendering time for the entire list improved from 180ms to about 21ms (an 88.33% improvement).
  • The total rendering time improved from about 220ms to about 42ms (an 80.91% improvement).
As the page itself is no longer affected by unnecessary re-renders outside of the memoized components, additional improvements have been achieved.
 
The rendering time has improved beyond the target of 100ms, now requiring only 42ms for each body condition selection.
👍
The UI delay the user might have felt has been eliminated.
There's nothing more I could wish for.
 

Optimization 5: Design Change for Improved Initial Rendering Time

Problem

notion image
During the initial page rendering, all 45 body conditions were rendered at once, making the initial rendering quite heavy.
As a result, it took around 1800ms for the user to view the first screen, and it needed improvement.
The goal was to reduce this time to less than one 1000ms.

Root Cause

Rendering all 45 body conditions at once was too much, and since the user wouldn't view all of them immediately, there was no need to render everything from the start. Therefore, rendering optimization through virtualization was necessary.
I was already aware of this, so the list was implemented using the FlatList component, which offers virtualization optimization. However, it wasn’t effective, and the issue lay in the component design.
 
notion image
Looking at the renderItem of FlatList, it was rendering by groups, not individual components.
 
notion image
From FlatList's perspective, it wasn't rendering 45 individual components, but rather just 5 components.
Each group contained many number of body condition components, so rendering by groups caused heaviness and didn't take full advantage of virtualization.

Solution

notion image
Instead of rendering the 45 body conditions in 5 groups, flatten the component structure to enable row-based rendering, allowing virtualization to work more efficiently.
 
notion image
With this approach, virtualization optimization worked properly, and the 45 body conditions were rendered in three stages:
  1. During the initial page render, only the necessary page elements and visible body conditions were rendered for the user to see.
  1. Shortly after, 20 additional body conditions were rendered behind.
  1. The remaining 20 body conditions were rendered naturally as the user scrolled.
 
This resulted in a significant improvement, reducing the time for the first screen to load from 1800ms to just 312ms.
 

Impact

Performance Enhancements

  • Faster Initial Rendering
    • Reduced initial page rendering time: 1995.6ms437.8ms (78% reduction)
    • Achieved through virtualization and optimized list component structure.
    • notion image
  • Optimized Interaction Performance
    • Reduced re-rendering costs during user interactions: 1100ms228ms42ms (96% reduction)
    • Enhanced efficiency by addressing redundant updates and simplifying component responsibilities.
    •  
      notion image

Business Impact

  • Avoided full recall of deployed units, saving extensive logistical costs. Specifically, a recall of 500 engine units would have incurred tablet replacement costs of 10 million KRW and required a 3-month turnaround period.
  • Eliminated the need for tablet replacement, reducing production costs. A bulk order of new tablets for upgrades (MOQ) would have cost 100 million KRW, and replacing the tablets would have led to an additional 7.5 million KRW in disposal costs.
  • Preserved profit margins by mitigating unnecessary financial burdens, saving 250,000 KRW per unit on the 500 units already produced. This approach ensures a positive impact on margins for future production as well.
 

Conclusion

This project demonstrates the impact of proper use of state management and component memoization, component architecture on performance, particularly in low-spec environments.
🔑
We shouldn't be satisfied with just achieving the desired behavior.
This experience taught me valuable lessons about preventing misuse and overuse by gaining a deeper understanding of the framework or library. It emphasized that using something with clear intent and understanding leads to greater advancement.
 
 
tbd
What I did for Rendering Optimizations