Fixing a Slow React App
Our React app was snail pace. The diagnosis was Redux, duplicate data, and a product architecture that loaded everyone's entire workspace into memory.
The diagnosis
Two things were doing the work. Same data lived in multiple slices of the store with different shapes; a store that redundant has a lot of surface area, where any deep mutation produces a new top-level object somewhere relevant to almost every component on screen.
The other half was where selectors were attached. Components were listening to large parents instead of the leaf data they actually needed. When Immer produced a new top-level object after any mutation, anything watching that top-level object re-rendered. Combined with the duplication, "any deep mutation" was the default state of the world.
Why memoization didn't fix it
Memoization is mostly a bandaid for poor Redux usage.
useMemo and React.memo are real tools and they have their place. If a
component is re-rendering because it's subscribed to the wrong slice of state,
no amount of memoizing the render output will save you. The render still gets
called every time the parent updates. Memoizing the inside of the function
doesn't change the rate at which the function is called.
The reality is that memoization does improve performance, its just not the solution. When we debug code with AI, or tell the AI that it has made a mistake, it is prone to reaching for the first tool that will fix the problem rather than investigating and finding the correct initial solution. The same is often true of us as engineers. An incident comes in, a complaint comes in, and the goal is to resolve it. The answer is rarely rearchitecture or a wider planning scope, because resource is rarely allocated for that. Got a component that rerenders too often? Memoize it and move on. Got a useEffect that fires when it isn't supposed to? Just eslint-ignore that exhaustive deps rule!
Part of this stems from a desire to avoid cascading issues. If you change the implementation of that useEffect so that it is compliant with the React lifecycle, you're risking a regression. Simply changing the dependencies list is unlikely to cause anything drastic (that won't eventually update on it's own anyway).
I digress. The solution isn't always whats quickest and easiest, and in my experience, the solution is rarely memoization. Let React handle that, whilst you focus on reducing real re-renders.
What actually worked
In rough impact order:
- Fixing the selectors. Subscribing to leaf data.
- Moving heavy work off the main thread. Some of the logic was synchronous, compute heavy, and on the render thread. Pushing it into a worker stopped blocking the UI on background recompute.
- The unfortunate nature of JS is that pushing logic to the worker thread REQUIRES a computationally intensive synchronous communication (JSON.stringify) or an overly complex solution (a-la Protobuf) - in both directions. Worker threads are only a solution if they don't have to communicate large payloads.
- Cleaning up duplicate data where we could, reducing memory usage.
- Memoization in places, after the above.
- We shipped one client new MacBooks because their devices weren't sufficient. I kid you not.
Relative impact of the actual fixes
The deeper memory problem
The bigger architectural issue is that the product is built around loading everything into memory. The entire workspace. Every entity, every relationship, every piece of metadata. Small workspaces work fine. Enterprise workspaces, which is what our largest customers run, are where the design breaks down. In some cases, these workspaces were over a gigabyte of data before any JavaScript object duplication nonsense.
We were partway to a better answer. IndexedDB was in the picture. Everything still lives in memory, though. IDB is a cache layer; the query layer is still the in-memory store.
The answer I'd advocate for, and didn't get to ship, is SQLite in OPFS. SQLite running in the browser's Origin Private File System gives you millisecond-range queries against actual structured data. Components could load only the slice they need, when they need it. The frontend's data layer becomes a real database with real query semantics. Of course, there are views in which we'd need most of that gigabyte, but thats fine! We can load it into memory when we need it.
That work didn't happen because the cost of refactoring around a query layer instead of a global store is not small, and it competes for time with everything else. As it stands, the global store was already haphazardly constructed over years of pivoting product demands and legacy solutions. These things happen, no codebase remains pristine forever.
The biggest single perf win
The biggest measurable perf win across this whole stretch came from outside the codebase. A customer had source data that pointed at entities that didn't exist. The app was repeatedly searching for those phantoms, chewing CPU on lookups that were always going to miss, pushing weighty errors to the console. We cleaned up the source data, the searches stopped, and the app got materially faster for that customer.
Key takeaway
If your React app feels slow, in this order: look at where your selectors are subscribing, because they're probably wrong. Look at how much data you're holding in memory, because it's probably more than you think. Look at the data itself, because it's probably dirtier than you think. Then look at memoization.
Frontend performance work eventually becomes data layer work. It becomes solving how you retrieve data, when you retrieve data, how quickly your data retrieves, and how much data you're storing. In many applications, the amount you're storing doesn't matter. In many applications, the compute you do on a data change is miniscule. This is not the case for Coalesce, a web based workspace, functionally an IDE.