Improving React Interaction Times by 4x

Solving common performance pitfalls with React tooling and hooks
Improving React Interaction Times by 4x

Causal is a cloud-based spreadsheet that lets you build and collaborate on complex financial models and data-heavy projects. Sometimes, Causal models get huge, which creates many challenges in keeping them fast — on both the backend and frontend.

One key challenge is UI interactions. As you can see in the video below, when we open the categories tab and try filling in a row, the UI gets quite laggy.

It might be hard to see when the app is laggy in this video. To make it easier, keep an eye on the “Frame Rate” popup in the top left corner – when it’s red or yellow, the main thread is frozen. (By the way, this is a built-in Chrome DevTools feature! You could enable it in DevTools → More Tools → Rendering → Frame Rendering Stats.

Here’s how Causal managed to speed up this interaction by almost 4× – with nearly every optimization changing just a few lines.

Profiling The Interaction

To optimize the interaction, we need to figure out what makes it slow. Our go-to tool for that is Chrome DevTools:

Open Chrome DevTools → Performance. Click record. Update one value. Wait a bit, and stop the recording.

There’s a lot of stuff in the recording, so it might be confusing if you’re seeing it for the first time. But that’s okay! What we need to pay attention to is just two areas:

The CPU row shows when the page was busy. The Main pane shows why the page was busy.

So what’s going on here? If you go through the recording and click through a bunch of rectangles, you’ll notice some patterns:‍

‍There are a lot of React renders. Specifically, every rectangle called <span class="inline-code">performSyncWorkOnRoot</span> is (roughly) a function that starts a React render cycle. And there are lots of them (precisely, 1325).

To search for a function name, press ⌘+F (or, if not using macOS, Ctrl+F). DevTools will highlight the first found match, and you can jump to the next ones.

‍Most of these React renders are caused by AG Grid. AG Grid is a library that Causal uses to render tables/grids:

‍

If you find any <span class="inline-code">performSyncWorkOnRoot</span> rectangle and then scroll up, you’ll see what caused that function to run (meaning, caused that React render). Most of the time, that will be some AG Grid code:

‍Some of the code runs several times. E.g., at the beginning of the recording, we call <span class="inline-code">GridApi.refreshServerSide</span> and <span class="inline-code">GridApi.refreshCells</span> two times in a row:

Later, some code seems to call <span class="inline-code">getRows</span> over and over and over again:

This is good! When we have some code that runs ten times in a row, we can improve a single run – and get a 10× improvement. And if some of these runs end up being unnecessary, we’ll be able to remove them altogether.

Let’s dive in.

AG Grid: Fixing An Extra Render

Across the recording, four chunks of JavaScript start with <span class="inline-code">GridApi.refreshServerSide</span>

Down the flame chart, these chunks of JavaScript cause many React rerenders. To figure out which components are being rendered, let’s scroll down and hunt for component names. (This works because to render a component, React calls it  – or its <span class="inline-code">.render()</span> method if it’s a class component.)

Why not use React Profiler? Another way to see which components are rendered is to record a trace in React Profiler. However, when you have a lot of rerenders, matching that trace with the DevTools Performance trace is hard – and if you make a mistake, you’ll end up optimizing the wrong rerenders.

If you click through the component names in the recording, you’ll realize every component is <span class="inline-code">RowContainerComp</span>. This is a component from AG Grid:

Why do these components render? To answer that, let’s switch to React Profiler and find these components there:

Open the React Profiler and enable “Record why each component rendered while profiling.” Then, click “Record” → update a variable → wait a bit → stop the recording → find any RowContainerComp in the recording. This isn’t perfect (you might pick up a wrong rerender) but is mostly precise.
Why use React Profiler this time? This time, we are using React Profiler. We’ve learned the component names, so we don’t need to match the trace with the DevTools performance pane anymore. A couple of other great ways to learn why a component rerenders are why-did-you-render and useWhyDidYouUpdate. They work great with first-party code but are harder to enable for third-party one (like AG Grid components).

As we see, <span class="inline-code">RowContainerComp</span> components rerender because their hook 2 changed. To find that hook, let’s switch from the Profiler to the Components pane – and match that hook with the source code:

‍

Why won’t we just count hooks in the component? That’s the most obvious approach, but it rarely works. That’s because React skips <span class="inline-code">useContex†</span> when counting hooks. (This is probably because <span class="inline-code">useContext</span> is implemented differently from other hooks.) Also, React doesn’t keep track of custom hooks. Instead, it counts every built-in hook (except <span class="inline-code">useContext</span>) inside. For example, if a component calls useSelector from Redux, and <span class="inline-code">useSelector</span> uses four React hooks inside, React profiler might show you "Hook 3 changed" when you should look for "custom hook 1".

Okay, so we figured out that <span class="inline-code">GridApi.refreshServerSide</span> renders a bunch of <span class="inline-code">RowContainerComp</span> components, and these components rerender because their hook 2 change.

Now, if you look through the component’s source code, you’ll notice that hook 2 is updated inside a <span class="inline-code">useEffect</span>.

And that <span class="inline-code">useEffect</span> triggers when the <span class="inline-code">rowCtrls</span> or the <span class="inline-code">domOrder</span> state changes:

This is not optimal! AG Grid is setting state from inside a <span class="inline-code">useEffect</span>. This means it schedules a new update right after another one has happened. Here’s the entire order of events:

  1. When the component mounts, it exposes several functions to the AG Grid core
  2. Later, AG Grid calls <span class="inline-code">compProxy.setRowCtrls</span>
  3. <span class="inline-code">compProxy.setRowCtrls</span> updates the <span class="inline-code">RowCtrls</span> state
  4. Because the state changed, the component rerenders
  5. The <span class="inline-code">RowCtrls</span> state state got updated, so React runs <span class="inline-code">useEffect</span> state
  6. Inside <span class="inline-code">useEffect</span> state, React calls <span class="inline-code">setRowCtrlsOrdered</span> state and updates our hook 2
  7. Because the state changed, the component rerenders again đŸ’„

As you can see, we’re rerendering the component twice just to update our hook 2! This isn’t great. If AG Grid called <span class="inline-code">setRowCtrlsOrdered</span> immediately at step 2 instead of 5, we’d be able to avoid an extra render.

So why don’t we make AG Grid do this? Using yarn patch, let’s patch the <span class="inline-code">@ag-grid-community/react</span> package to eliminate the extra render:

Full patch. We’ve notified AG Grid — unfortunately, they're not accepting PRs form the community.

This alone cuts the number of rerenders in half – and, because <span class="inline-code">RowContainerComp</span> is rendered outside <span class="inline-code">GridApi.refreshServerSide()</span> calls too, shaves off around 15-20% of the execution time.

But we’re not done with AG Grid yet.

AG Grid: Removing The Renders

The <span class="inline-code">RowContainerComp</span> components are containers for different parts of the grid:

These components render every time we type into the editor. We just removed half of these renders. But there’s still another half, and it’s probably unnecessary – as nothing in these components changes visually.

What’s causing these renders? As we learned in the previous section, <span class="inline-code">RowContainerComps</span> rerender when AG Grid calls <span class="inline-code">compProxy.setRowCtrls</span>. In every call, AG Grid passes a new <span class="inline-code">rowCtrls</span> array. Let’s add a logpoint to see how the array looks:

and check the console output:

Woah, doesn’t every log look the same?

And indeed. If you debug this a bit, you’ll realize that:

  1. The <span class="inline-code">compProxy.setRowCtrls</span> array that the component receives is always different (this is because AG Grid is re-creating it with .filter() before passing it in.
  2. All items in that array are identical <span class="inline-code">===</span> across rerenders

Inside <span class="inline-code">RowContainerComp</span>, AG Grid never touches the <span class="inline-code">rowCtrls</span> array – it only maps its items. So, if the array items don’t change, why should <span class="inline-code">RowContainerComp</span> rerender either?

We can prevent this extra render by doing a shallow equality check:

This saves a lot of time. Because on every cell update, <span class="inline-code">RowContainerComp</span> components rerender 1568 times (!), eliminating all renders cuts off another 15-30% of the total JS cost.

Running <span class="inline-code">useEffect</span> Less Often

Here are a few other parts of the recording:

In these parts, we call a function called <span class="inline-code">gridApi.refreshCells()</span>. This function gets called four times and, in total, takes around 5-10% of the JavaScript cost.

Here’s the Causal code that calls <span class="inline-code">gridApi.refreshCells()</span>

// ⚠ Hacky:
// Hard refresh if autocomplete changes. This works around issue #XXX in the ShowFormulas view
useEffect(() => {
  setTimeout(() => {
    // Note: we're scheduling refreshCells() in a new task.
    // This ensures all previous AG Grid updates have time to propagate
    gridApi.refreshCells({ force: true });
  }, 0);
}, [gridApi, autocompleteVariables]);

‍

This is an unfortunate hack (one of the few which every codebase has that works around an issue with code editor autocomplete occasionally not picking up new variables.

The workaround is supposed to run every time a new variable gets added or removed. However, currently, it runs way more frequently. That’s because <span class="inline-code">autocompleteVariables</span> is a deeply nested object with a bunch of other information about variables, including their values:

// The `autocompleteVariables` object (simplified)
{
  "variable-id": {
    name: "myVariable",
    type: "Variable",
    dimensions: [...],
    model: ...,
  },
  ...
}

‍

When you type in the cell, a few variables update their values. That causes <span class="inline-code">autocompleteVariables</span> to update a few times – and triggers a <span class="inline-code">gridApi.refreshCells()</span> call every time.

We don’t need these <span class="inline-code">gridApi.refreshCells()</span> calls. How can we avoid them?

Okay, so we need a way to call a <span class="inline-code">gridApi.refreshCells()</span> only when a new variable is added or removed.

A naive way to do that would be to rewrite useEffect dependencies like this:

useEffect(() => {
  // ...
}, [gridApi, autocompleteVariables]);

↓

useEffect(() => {
  // ...
}, [gridApi, autocompleteVariables.length]);

‍

This will work in most cases. However, if we add one variable and remove another one simultaneously, the workaround won’t run.

A proper way to do that would be to move <span class="inline-code">gridApi.refreshCells()</span> to the code that adds or removes a variable – e.g., to a Redux saga that handles the corresponding action. However, this isn’t a simple change. The logic that uses gridApi is concentrated in a single component. Exposing <span class="inline-code">gridApi()</span> to the Redux code would require us to break/change several abstractions. We’re working on this, but this will take time.

Instead, while we’re working on a proper solution, why don’t we hack a bit more? 😅

useEffect(() => {
  // ...
}, [gridApi, autocompleteVariables]);
useEffect(() => {

↓

useEffect(() => {
  // ...
}, [gridApi, Object.keys(autocompleteVariables).sort().join(',')]);

‍

With this change, <span class="inline-code">useEffect</span> will depend only on concrete variable IDs inside <span class="inline-code">autocompleteVariables</span>. Unless any variable ids get added or removed, the <span class="inline-code">useEffect</span> shouldn’t run anymore. (This assumes none of the variable ids include a , character, which is true in our case.).

Terrible? Yes. Temporary, contained, and easy to delete, bearing the minimal technical debt? Also yes. Solves the real issue? Absolutely yes. The real world is about tradeoffs, and sometimes you have to write less-than-optimal code if it makes your users’ life better.

Just like that, we save another 5-10% of the JavaScript execution time.

Deep <span class="inline-code">areEqual</span>

There are a few bits in the performance trace of a cell update that look like this:

What happens here is we have a function called <span class="inline-code">areEqual</span>. This function calls a function called <span class="inline-code">areEquivalent</span>– and then <span class="inline-code">areEquivalent</span>calls itself multiple times, over and over again. Does this look like anything to you?

Yeah, it’s a deep equality comparison. And on a 2020 MacBook Pro, it takes ~90 ms.

The <span class="inline-code">areEqual</span> function comes from AG Grid. Here’s how it’s called:

  1. React calls componentDidUpdate() in AG Grid whenever the component rerenders:
class AgGridReactUi {
  componentDidUpdate(prevProps) {
    this.processPropsChanges(prevProps, this.props);
  }
}
  1. <span class="inline-code">componentDidUpdate()</span> invokes <span class="inline-code">processPropChanges():</span>
public processPropsChanges(prevProps: any, nextProps: any) {
    const changes = {};

    this.extractGridPropertyChanges(prevProps, nextProps, changes);
    this.extractDeclarativeColDefChanges(nextProps, changes);

    this.processChanges(changes);
}
  1. Among other things, <span class="inline-code">processPropChanges()</span> calls a function called <span class="inline-code">extractGridPropertyChanges()</span>
  1. <span class="inline-code">extractGridPropertyChanges()</span> then performs a deep comparison on every prop passed into <span class="inline-code">AgGridReactUi</span>
// The code is simplified
private extractGridPropertyChanges(prevProps: any, nextProps: any, changes: any) {
    Object.keys(nextProps).forEach(propKey => {
        if (_.includes(ComponentUtil.ALL_PROPERTIES, propKey)) {
            const changeDetectionStrategy = this.changeDetectionService.getStrategy(this.getStrategyTypeForProp(propKey));

            // ↓ Here
            if (!changeDetectionStrategy.areEqual(prevProps[propKey], nextProps[propKey])) {
                // ...
            }
        }
    });
}

If some of these props are huge and change significantly, the deep comparison will take a lot of time. Unfortunately, this is precisely what’s happening here.

With a bit of debugging and <span class="inline-code">console.time()</span>, we find out that the expensive prop is context. context is an object holding a bunch of variables that we need to pass down to grid components. The object changes for good.

const context: GridContext = useMemo(
  (): GridContext => ({
    allDimensions,
    autocompleteVariables,
    // ↓ A few values in the model change (as they should).
    // This rebuilds the `editorModel` – and the `context` object itself
    editorModel, 
    filteredDimensions,
    isReadOnly,
    modelId,
    scenarioId: activeScenario.id,
    showFormulas,
  }),
  [
    activeScenario.id,
    allDimensions,
    autocompleteVariables,
    editorModel,
    filteredDimensions,
    isReadOnly,
    modelId,
    showFormulas,
  ],
}

‍

However, using a deep comparison on such a massive object is bad and unnecessary. The object is memoized, so we can just <span class="inline-code">===</span> it to figure out whether it changed. But how do we do that?


AG Grid supports several comparison strategies for props. One of them implements a <span class="inline-code">===</span> comparison:

export enum ChangeDetectionStrategyType {
  IdentityCheck = 'IdentityCheck',   // Uses === to compare objects
  DeepValueCheck = 'DeepValueCheck', // Uses deep comparison to compare objects
  NoCheck = 'NoCheck'                // Always considers objects different
}

‍

However, based on the source code, we can change the strategy only for the <span class="inline-code">rowData</span> prop

// This function chooses how to compare any given prop
getStrategyTypeForProp(propKey) {
  if (propKey === 'rowData') {
    if (this.props.rowDataChangeDetectionStrategy) {
      return this.props.rowDataChangeDetectionStrategy;
    }
    // ...
  }

  return ChangeDetectionStrategyType.DeepValueCheck;
}

‍

But nothing is preventing us from patching AG Grid, right? Using yarn patch, like we did above, let’s add a few lines into the <span class="inline-code">getStrategyTypeForProp()</span> function.

getStrategyTypeForProp(propKey) {
  // NEW
  if (this.props.changeDetectionStrategies && propKey in this.props.changeDetectionStrategies) {
    return this.props.changeDetectionStrategies[propKey];
  }
  // END OF NEW

  if (propKey === 'rowData') {
    if (this.props.rowDataChangeDetectionStrategy) {
      return this.props.rowDataChangeDetectionStrategy;
    }
    // ...
  }

  // all other cases will default to DeepValueCheck
  return ChangeDetectionStrategyType.DeepValueCheck;
}

‍

With this change, we can specify a custom comparison strategy for the <span class="inline-code">context</span> prop

import { ChangeDetectionStrategyType } from "@ag-grid-community/react/lib/shared/changeDetectionService";

// ...


And, just like that, we save another 3-5% of the JavaScript cost.

‍What’s Still In The Works

More Granular Updates

You might’ve noticed that for one update of a category value, we rerender the data grid four times:

Four renders are three renders too many. The UI should update only once if a user makes a single change. However, solving this is challenging.

Here’s a <span class="inline-code">useEffect</span> that rerenders the data grid:

useEffect(() => {
  const pathsToRefresh: { route: RowId[] }[] = [];
  for (const [pathString, oldRows] of rowCache.current.entries()) {
    // Fill the pathsToRefresh object (code omitted)
  }

  for (const refreshParams of pathsToRefresh) {
    gridApi?.refreshServerSide(refreshParams);
  }
}, [editorModel, gridApi, variableDimensionsLookup, activeScenario, variableGetterRef]);

‍

To figure out what’s causing this <span class="inline-code">useEffect</span>  to re-run, let’s use<span class="inline-code">useWhyDidYouUpdate</span>

This tells us <span class="inline-code">useEffect</span>  re-runs because <span class="inline-code">EditorModel</span>  and <span class="inline-code">variableDimensionsLookup</span> objects change. But how? With a little custom deepCompare function, we can figure this out:

This is how <span class="inline-code">EditorModel</span> changes if you update a single category value from <span class="inline-code">69210</span>to <span class="inline-code">5</span>. As you see, a single change causes four consecutive updates. variableDimensionsLookup changes similarly (not shown).

One category update causes four <span class="inline-code">EditorModel</span> updates. Some of these updates are caused by suboptimal Redux sagas (which we’re fixing). Others (like update 4, which rebuilds the model but doesn’t change anything) may be fixed by adding extra memoized selectors or comparison checks.

But there’s also a deeper, fundamental issue that is harder to fix. With React and Redux, the code we write by default is not performant. React and Redux don’t help us to fall into a pit of success.

To make the code fast, we need to remember to memoize most computations – both in components (with <span class="inline-code">UseMemo</span> and <span class="inline-code">UseCallback</span>) and in Redux selectors (with <span class="inline-code">reselect</span>). If we don’t do that, some components will rerender unnecessarily. That’s cheap in smaller apps but scales really, really poorly as your app grows.

And some of these computations are not really memorizable:

// How do you prevent `variableValues` from being recalculated
// when `editorModel.variables` changes - but
// `editorModel.variables[...].value`s stay the same?
// ("Deep comparison" is a possible answer, but it's very expensive with large objects.)
const variableValues = useMemo(() => {
  return Object.values(editorModel.variables).map(variable => variable.value);
}, [editorModel.variables]);

‍

This also affects the <span class="inline-code">useEffect</span> we saw above:

useEffect(() => {
  // Re-render the data grid
}, [editorModel, /* ... */]);
// ↑ Re-runs on every `editorModel` change
// But how do you express "re-run it only when `editorModel.variables[...].value`s change"?

‍

We’re working on solving these extra renders (e.g., by moving logic away from useEffects). In our tests, this should cut another 10-30% off the JavaScript cost. But this will take some time.

To be fair, React is also working on an auto-memoizing compiler which should reduce recalculations.

<span class="inline-code">useSelector</span> vs. <span class="inline-code">useStore</span>

If you have some data in a Redux store, and you want to access that data in an <span class="inline-code">onChange</span> callback, how would you do that?

Here’s the most straightforward way:

const CellWrapper = () => {
  const editorModel = useSelector(state => state.editorModel)
  const onChange = () => {
    // Do something with editorModel
  }

  return 
}

‍

If <span class="inline-code">Cell</span> is expensive to rerender, and you want to avoid rerendering it unnecessarily, you might wrap onChange with <span class="inline-code">useCallback</span>

const CellWrapper = () => {
  const editorModel = useSelector(state => state.editorModel)
  const onChange = useCallback(() => {
    // Do something with editorModel
  }, [editorModel])

  return 
}

‍

However, what will happen if <span class="inline-code">editorModel</span> changes very often? Right – <span class="inline-code">useCallback</span> will regenerate <span class="inline-code">onChange</span> whenever <span class="inline-code">editorModel</span> changes, and <span class="inline-code">Cell</span> will rerender every time.

Here’s an alternative approach that doesn’t have this issue:

const CellWrapper = () => {
  const store = useStore()
  const onChange = useCallback(() => {
    const editorModel = store.getState().editorModel
    // Do something with editorModel
  }, [store])

  return 
}

‍

This approach relies on Redux’s <span class="inline-code">useStore(</span>) hook.

  • Unlike <span class="inline-code">useSelector()</span>, <span class="inline-code">useStore()</span> returns the full store object
  • Also, unlike <span class="inline-code">useSelector()</span>, <span class="inline-code">useStore()</span> can’t trigger a component render. But we don’t need to, either! The component output doesn’t rely on the <span class="inline-code">EditorModel</span>state. Only the <span class="inline-code">onChange</span> callback needs it – and we can safely delay the editorModel read until then.

Causal has a bunch of components using <span class="inline-code">useCallback</span> and <span class="inline-code">useSelector</span> like above. They would benefit from this optimization, so we’re gradually implementing it. We didn’t see any immediate improvements in the interaction we were optimizing, but we expect this to reduce rerenders in a few other places.

In the future, wrapping the callback with <span class="inline-code">useEvent</span> instead of <span class="inline-code">useCallback</span> might help solve this issue too.

What Didn't Work

Here’s another bit of the performance trace:

In this part of the trace, we receive a new binary-encoded model from the server and parse it using protobuf. This is a self-contained operation (you call a single function, and it returns 400-800 ms later), and it doesn’t need to access DOM. This makes it a perfect candidate for a Web Worker.

Web What? Web Workers are a way to run some expensive JavaScript in a separate thread. This allows keeping the page responsive while that JavaScript is running.

The easiest way to move a function into a web worker is to wrap it with comlink:

import * as eval_pb from "causal-common/eval/proto/generated/eval_pb";
// ...
const response = eval_pb.Response.decode(res);

↓

// worker.ts
import * as Comlink from "comlink";
import * as eval_pb from "causal-common/eval/proto/generated/eval_pb";

const parseResponse = (res) => eval_pb.Response.decode(res);

Comlink.expose({
  parseResponse
});
// index.ts
import * as Comlink from "comlink";

const worker = Comlink.wrap(new Worker(new URL('./worker.ts', import.meta.url)))
// ...
const response = await worker.parseResponse(res);

‍

This relies on webpack 5’s built-in worker support

If we do that and record a new trace, we’ll discover that parsing was successfully moved to the worker thread:

However, weirdly, the overall JS cost will increase. If we investigate this, we’ll discover that we now have two additional flat 400-1200ms long chunks of JavaScript:

Worker thread.
Main thread.

It turns out that moving stuff to a web worker isn’t free. Whenever you pass data from and to a web worker, the browser has to serialize and deserialize it. Typically, this is cheap; however, for large objects, this may take some time. For us, because our model is large, this takes a lot of time. Serializing and deserializing end up longer than the actual parsing operation!

Unfortunately, this optimization didn’t work for us. Instead, as an experiment, we’re currently working on selective data loading (fetching only visible rows instead of the whole model). It dramatically reduces the parsing cost:

With selective data loading, the parsing costs go down from 500-1500 ms to 1-5.

Results

So, how much did these optimizations help? In our test model with ~100 categories, implementing the optimizations (and enabling selective data loading) reduces the JavaScript cost by almost four times đŸ€Ż

Before (median run out of 5).
After (median run out of 5).

With these optimizations, updating category cells becomes a much smoother experience:

We still have chunks of yellow/red in the recording, but they’re much smaller – and intertwined with blue!

We’ve managed to dramatically lower the interaction responsiveness with just a few precise changes – but we’re still far from done. Thanks to Ivan from 3Perf for helping us with this investigation. If you’re interested in helping us get to sub-100ms interaction times, you should consider joining the Causal team – email lukas@causal.app!

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.
PERSONAL FINANCE
Buy vs Rent
Should you buy a house or rent?
Startur
B2B SaaS Revenue
Forecast your inbound and outbound leads to determine revenue, and understand what kind of sales funnel you need to hit your revenue targets.
FINANCE
Detailed Headcount Model
Understand the breakdown of your headcount and payroll costs by Department (Sales, Engineering, etc.) and plan your future hires.

Improving React Interaction Times by 4x

Nov 16, 2022
By 
Lukas Köbis
Table of Contents
Heading 2
Heading 3

Causal is a cloud-based spreadsheet that lets you build and collaborate on complex financial models and data-heavy projects. Sometimes, Causal models get huge, which creates many challenges in keeping them fast — on both the backend and frontend.

One key challenge is UI interactions. As you can see in the video below, when we open the categories tab and try filling in a row, the UI gets quite laggy.

It might be hard to see when the app is laggy in this video. To make it easier, keep an eye on the “Frame Rate” popup in the top left corner – when it’s red or yellow, the main thread is frozen. (By the way, this is a built-in Chrome DevTools feature! You could enable it in DevTools → More Tools → Rendering → Frame Rendering Stats.

Here’s how Causal managed to speed up this interaction by almost 4× – with nearly every optimization changing just a few lines.

Profiling The Interaction

To optimize the interaction, we need to figure out what makes it slow. Our go-to tool for that is Chrome DevTools:

Open Chrome DevTools → Performance. Click record. Update one value. Wait a bit, and stop the recording.

There’s a lot of stuff in the recording, so it might be confusing if you’re seeing it for the first time. But that’s okay! What we need to pay attention to is just two areas:

The CPU row shows when the page was busy. The Main pane shows why the page was busy.

So what’s going on here? If you go through the recording and click through a bunch of rectangles, you’ll notice some patterns:‍

‍There are a lot of React renders. Specifically, every rectangle called <span class="inline-code">performSyncWorkOnRoot</span> is (roughly) a function that starts a React render cycle. And there are lots of them (precisely, 1325).

To search for a function name, press ⌘+F (or, if not using macOS, Ctrl+F). DevTools will highlight the first found match, and you can jump to the next ones.

‍Most of these React renders are caused by AG Grid. AG Grid is a library that Causal uses to render tables/grids:

‍

If you find any <span class="inline-code">performSyncWorkOnRoot</span> rectangle and then scroll up, you’ll see what caused that function to run (meaning, caused that React render). Most of the time, that will be some AG Grid code:

‍Some of the code runs several times. E.g., at the beginning of the recording, we call <span class="inline-code">GridApi.refreshServerSide</span> and <span class="inline-code">GridApi.refreshCells</span> two times in a row:

Later, some code seems to call <span class="inline-code">getRows</span> over and over and over again:

This is good! When we have some code that runs ten times in a row, we can improve a single run – and get a 10× improvement. And if some of these runs end up being unnecessary, we’ll be able to remove them altogether.

Let’s dive in.

AG Grid: Fixing An Extra Render

Across the recording, four chunks of JavaScript start with <span class="inline-code">GridApi.refreshServerSide</span>

Down the flame chart, these chunks of JavaScript cause many React rerenders. To figure out which components are being rendered, let’s scroll down and hunt for component names. (This works because to render a component, React calls it  – or its <span class="inline-code">.render()</span> method if it’s a class component.)

Why not use React Profiler? Another way to see which components are rendered is to record a trace in React Profiler. However, when you have a lot of rerenders, matching that trace with the DevTools Performance trace is hard – and if you make a mistake, you’ll end up optimizing the wrong rerenders.

If you click through the component names in the recording, you’ll realize every component is <span class="inline-code">RowContainerComp</span>. This is a component from AG Grid:

Why do these components render? To answer that, let’s switch to React Profiler and find these components there:

Open the React Profiler and enable “Record why each component rendered while profiling.” Then, click “Record” → update a variable → wait a bit → stop the recording → find any RowContainerComp in the recording. This isn’t perfect (you might pick up a wrong rerender) but is mostly precise.
Why use React Profiler this time? This time, we are using React Profiler. We’ve learned the component names, so we don’t need to match the trace with the DevTools performance pane anymore. A couple of other great ways to learn why a component rerenders are why-did-you-render and useWhyDidYouUpdate. They work great with first-party code but are harder to enable for third-party one (like AG Grid components).

As we see, <span class="inline-code">RowContainerComp</span> components rerender because their hook 2 changed. To find that hook, let’s switch from the Profiler to the Components pane – and match that hook with the source code:

‍

Why won’t we just count hooks in the component? That’s the most obvious approach, but it rarely works. That’s because React skips <span class="inline-code">useContex†</span> when counting hooks. (This is probably because <span class="inline-code">useContext</span> is implemented differently from other hooks.) Also, React doesn’t keep track of custom hooks. Instead, it counts every built-in hook (except <span class="inline-code">useContext</span>) inside. For example, if a component calls useSelector from Redux, and <span class="inline-code">useSelector</span> uses four React hooks inside, React profiler might show you "Hook 3 changed" when you should look for "custom hook 1".

Okay, so we figured out that <span class="inline-code">GridApi.refreshServerSide</span> renders a bunch of <span class="inline-code">RowContainerComp</span> components, and these components rerender because their hook 2 change.

Now, if you look through the component’s source code, you’ll notice that hook 2 is updated inside a <span class="inline-code">useEffect</span>.

And that <span class="inline-code">useEffect</span> triggers when the <span class="inline-code">rowCtrls</span> or the <span class="inline-code">domOrder</span> state changes:

This is not optimal! AG Grid is setting state from inside a <span class="inline-code">useEffect</span>. This means it schedules a new update right after another one has happened. Here’s the entire order of events:

  1. When the component mounts, it exposes several functions to the AG Grid core
  2. Later, AG Grid calls <span class="inline-code">compProxy.setRowCtrls</span>
  3. <span class="inline-code">compProxy.setRowCtrls</span> updates the <span class="inline-code">RowCtrls</span> state
  4. Because the state changed, the component rerenders
  5. The <span class="inline-code">RowCtrls</span> state state got updated, so React runs <span class="inline-code">useEffect</span> state
  6. Inside <span class="inline-code">useEffect</span> state, React calls <span class="inline-code">setRowCtrlsOrdered</span> state and updates our hook 2
  7. Because the state changed, the component rerenders again đŸ’„

As you can see, we’re rerendering the component twice just to update our hook 2! This isn’t great. If AG Grid called <span class="inline-code">setRowCtrlsOrdered</span> immediately at step 2 instead of 5, we’d be able to avoid an extra render.

So why don’t we make AG Grid do this? Using yarn patch, let’s patch the <span class="inline-code">@ag-grid-community/react</span> package to eliminate the extra render:

Full patch. We’ve notified AG Grid — unfortunately, they're not accepting PRs form the community.

This alone cuts the number of rerenders in half – and, because <span class="inline-code">RowContainerComp</span> is rendered outside <span class="inline-code">GridApi.refreshServerSide()</span> calls too, shaves off around 15-20% of the execution time.

But we’re not done with AG Grid yet.

AG Grid: Removing The Renders

The <span class="inline-code">RowContainerComp</span> components are containers for different parts of the grid:

These components render every time we type into the editor. We just removed half of these renders. But there’s still another half, and it’s probably unnecessary – as nothing in these components changes visually.

What’s causing these renders? As we learned in the previous section, <span class="inline-code">RowContainerComps</span> rerender when AG Grid calls <span class="inline-code">compProxy.setRowCtrls</span>. In every call, AG Grid passes a new <span class="inline-code">rowCtrls</span> array. Let’s add a logpoint to see how the array looks:

and check the console output:

Woah, doesn’t every log look the same?

And indeed. If you debug this a bit, you’ll realize that:

  1. The <span class="inline-code">compProxy.setRowCtrls</span> array that the component receives is always different (this is because AG Grid is re-creating it with .filter() before passing it in.
  2. All items in that array are identical <span class="inline-code">===</span> across rerenders

Inside <span class="inline-code">RowContainerComp</span>, AG Grid never touches the <span class="inline-code">rowCtrls</span> array – it only maps its items. So, if the array items don’t change, why should <span class="inline-code">RowContainerComp</span> rerender either?

We can prevent this extra render by doing a shallow equality check:

This saves a lot of time. Because on every cell update, <span class="inline-code">RowContainerComp</span> components rerender 1568 times (!), eliminating all renders cuts off another 15-30% of the total JS cost.

Running <span class="inline-code">useEffect</span> Less Often

Here are a few other parts of the recording:

In these parts, we call a function called <span class="inline-code">gridApi.refreshCells()</span>. This function gets called four times and, in total, takes around 5-10% of the JavaScript cost.

Here’s the Causal code that calls <span class="inline-code">gridApi.refreshCells()</span>

// ⚠ Hacky:
// Hard refresh if autocomplete changes. This works around issue #XXX in the ShowFormulas view
useEffect(() => {
  setTimeout(() => {
    // Note: we're scheduling refreshCells() in a new task.
    // This ensures all previous AG Grid updates have time to propagate
    gridApi.refreshCells({ force: true });
  }, 0);
}, [gridApi, autocompleteVariables]);

‍

This is an unfortunate hack (one of the few which every codebase has that works around an issue with code editor autocomplete occasionally not picking up new variables.

The workaround is supposed to run every time a new variable gets added or removed. However, currently, it runs way more frequently. That’s because <span class="inline-code">autocompleteVariables</span> is a deeply nested object with a bunch of other information about variables, including their values:

// The `autocompleteVariables` object (simplified)
{
  "variable-id": {
    name: "myVariable",
    type: "Variable",
    dimensions: [...],
    model: ...,
  },
  ...
}

‍

When you type in the cell, a few variables update their values. That causes <span class="inline-code">autocompleteVariables</span> to update a few times – and triggers a <span class="inline-code">gridApi.refreshCells()</span> call every time.

We don’t need these <span class="inline-code">gridApi.refreshCells()</span> calls. How can we avoid them?

Okay, so we need a way to call a <span class="inline-code">gridApi.refreshCells()</span> only when a new variable is added or removed.

A naive way to do that would be to rewrite useEffect dependencies like this:

useEffect(() => {
  // ...
}, [gridApi, autocompleteVariables]);

↓

useEffect(() => {
  // ...
}, [gridApi, autocompleteVariables.length]);

‍

This will work in most cases. However, if we add one variable and remove another one simultaneously, the workaround won’t run.

A proper way to do that would be to move <span class="inline-code">gridApi.refreshCells()</span> to the code that adds or removes a variable – e.g., to a Redux saga that handles the corresponding action. However, this isn’t a simple change. The logic that uses gridApi is concentrated in a single component. Exposing <span class="inline-code">gridApi()</span> to the Redux code would require us to break/change several abstractions. We’re working on this, but this will take time.

Instead, while we’re working on a proper solution, why don’t we hack a bit more? 😅

useEffect(() => {
  // ...
}, [gridApi, autocompleteVariables]);
useEffect(() => {

↓

useEffect(() => {
  // ...
}, [gridApi, Object.keys(autocompleteVariables).sort().join(',')]);

‍

With this change, <span class="inline-code">useEffect</span> will depend only on concrete variable IDs inside <span class="inline-code">autocompleteVariables</span>. Unless any variable ids get added or removed, the <span class="inline-code">useEffect</span> shouldn’t run anymore. (This assumes none of the variable ids include a , character, which is true in our case.).

Terrible? Yes. Temporary, contained, and easy to delete, bearing the minimal technical debt? Also yes. Solves the real issue? Absolutely yes. The real world is about tradeoffs, and sometimes you have to write less-than-optimal code if it makes your users’ life better.

Just like that, we save another 5-10% of the JavaScript execution time.

Deep <span class="inline-code">areEqual</span>

There are a few bits in the performance trace of a cell update that look like this:

What happens here is we have a function called <span class="inline-code">areEqual</span>. This function calls a function called <span class="inline-code">areEquivalent</span>– and then <span class="inline-code">areEquivalent</span>calls itself multiple times, over and over again. Does this look like anything to you?

Yeah, it’s a deep equality comparison. And on a 2020 MacBook Pro, it takes ~90 ms.

The <span class="inline-code">areEqual</span> function comes from AG Grid. Here’s how it’s called:

  1. React calls componentDidUpdate() in AG Grid whenever the component rerenders:
class AgGridReactUi {
  componentDidUpdate(prevProps) {
    this.processPropsChanges(prevProps, this.props);
  }
}
  1. <span class="inline-code">componentDidUpdate()</span> invokes <span class="inline-code">processPropChanges():</span>
public processPropsChanges(prevProps: any, nextProps: any) {
    const changes = {};

    this.extractGridPropertyChanges(prevProps, nextProps, changes);
    this.extractDeclarativeColDefChanges(nextProps, changes);

    this.processChanges(changes);
}
  1. Among other things, <span class="inline-code">processPropChanges()</span> calls a function called <span class="inline-code">extractGridPropertyChanges()</span>
  1. <span class="inline-code">extractGridPropertyChanges()</span> then performs a deep comparison on every prop passed into <span class="inline-code">AgGridReactUi</span>
// The code is simplified
private extractGridPropertyChanges(prevProps: any, nextProps: any, changes: any) {
    Object.keys(nextProps).forEach(propKey => {
        if (_.includes(ComponentUtil.ALL_PROPERTIES, propKey)) {
            const changeDetectionStrategy = this.changeDetectionService.getStrategy(this.getStrategyTypeForProp(propKey));

            // ↓ Here
            if (!changeDetectionStrategy.areEqual(prevProps[propKey], nextProps[propKey])) {
                // ...
            }
        }
    });
}

If some of these props are huge and change significantly, the deep comparison will take a lot of time. Unfortunately, this is precisely what’s happening here.

With a bit of debugging and <span class="inline-code">console.time()</span>, we find out that the expensive prop is context. context is an object holding a bunch of variables that we need to pass down to grid components. The object changes for good.

const context: GridContext = useMemo(
  (): GridContext => ({
    allDimensions,
    autocompleteVariables,
    // ↓ A few values in the model change (as they should).
    // This rebuilds the `editorModel` – and the `context` object itself
    editorModel, 
    filteredDimensions,
    isReadOnly,
    modelId,
    scenarioId: activeScenario.id,
    showFormulas,
  }),
  [
    activeScenario.id,
    allDimensions,
    autocompleteVariables,
    editorModel,
    filteredDimensions,
    isReadOnly,
    modelId,
    showFormulas,
  ],
}

‍

However, using a deep comparison on such a massive object is bad and unnecessary. The object is memoized, so we can just <span class="inline-code">===</span> it to figure out whether it changed. But how do we do that?


AG Grid supports several comparison strategies for props. One of them implements a <span class="inline-code">===</span> comparison:

export enum ChangeDetectionStrategyType {
  IdentityCheck = 'IdentityCheck',   // Uses === to compare objects
  DeepValueCheck = 'DeepValueCheck', // Uses deep comparison to compare objects
  NoCheck = 'NoCheck'                // Always considers objects different
}

‍

However, based on the source code, we can change the strategy only for the <span class="inline-code">rowData</span> prop

// This function chooses how to compare any given prop
getStrategyTypeForProp(propKey) {
  if (propKey === 'rowData') {
    if (this.props.rowDataChangeDetectionStrategy) {
      return this.props.rowDataChangeDetectionStrategy;
    }
    // ...
  }

  return ChangeDetectionStrategyType.DeepValueCheck;
}

‍

But nothing is preventing us from patching AG Grid, right? Using yarn patch, like we did above, let’s add a few lines into the <span class="inline-code">getStrategyTypeForProp()</span> function.

getStrategyTypeForProp(propKey) {
  // NEW
  if (this.props.changeDetectionStrategies && propKey in this.props.changeDetectionStrategies) {
    return this.props.changeDetectionStrategies[propKey];
  }
  // END OF NEW

  if (propKey === 'rowData') {
    if (this.props.rowDataChangeDetectionStrategy) {
      return this.props.rowDataChangeDetectionStrategy;
    }
    // ...
  }

  // all other cases will default to DeepValueCheck
  return ChangeDetectionStrategyType.DeepValueCheck;
}

‍

With this change, we can specify a custom comparison strategy for the <span class="inline-code">context</span> prop

import { ChangeDetectionStrategyType } from "@ag-grid-community/react/lib/shared/changeDetectionService";

// ...


And, just like that, we save another 3-5% of the JavaScript cost.

‍What’s Still In The Works

More Granular Updates

You might’ve noticed that for one update of a category value, we rerender the data grid four times:

Four renders are three renders too many. The UI should update only once if a user makes a single change. However, solving this is challenging.

Here’s a <span class="inline-code">useEffect</span> that rerenders the data grid:

useEffect(() => {
  const pathsToRefresh: { route: RowId[] }[] = [];
  for (const [pathString, oldRows] of rowCache.current.entries()) {
    // Fill the pathsToRefresh object (code omitted)
  }

  for (const refreshParams of pathsToRefresh) {
    gridApi?.refreshServerSide(refreshParams);
  }
}, [editorModel, gridApi, variableDimensionsLookup, activeScenario, variableGetterRef]);

‍

To figure out what’s causing this <span class="inline-code">useEffect</span>  to re-run, let’s use<span class="inline-code">useWhyDidYouUpdate</span>

This tells us <span class="inline-code">useEffect</span>  re-runs because <span class="inline-code">EditorModel</span>  and <span class="inline-code">variableDimensionsLookup</span> objects change. But how? With a little custom deepCompare function, we can figure this out:

This is how <span class="inline-code">EditorModel</span> changes if you update a single category value from <span class="inline-code">69210</span>to <span class="inline-code">5</span>. As you see, a single change causes four consecutive updates. variableDimensionsLookup changes similarly (not shown).

One category update causes four <span class="inline-code">EditorModel</span> updates. Some of these updates are caused by suboptimal Redux sagas (which we’re fixing). Others (like update 4, which rebuilds the model but doesn’t change anything) may be fixed by adding extra memoized selectors or comparison checks.

But there’s also a deeper, fundamental issue that is harder to fix. With React and Redux, the code we write by default is not performant. React and Redux don’t help us to fall into a pit of success.

To make the code fast, we need to remember to memoize most computations – both in components (with <span class="inline-code">UseMemo</span> and <span class="inline-code">UseCallback</span>) and in Redux selectors (with <span class="inline-code">reselect</span>). If we don’t do that, some components will rerender unnecessarily. That’s cheap in smaller apps but scales really, really poorly as your app grows.

And some of these computations are not really memorizable:

// How do you prevent `variableValues` from being recalculated
// when `editorModel.variables` changes - but
// `editorModel.variables[...].value`s stay the same?
// ("Deep comparison" is a possible answer, but it's very expensive with large objects.)
const variableValues = useMemo(() => {
  return Object.values(editorModel.variables).map(variable => variable.value);
}, [editorModel.variables]);

‍

This also affects the <span class="inline-code">useEffect</span> we saw above:

useEffect(() => {
  // Re-render the data grid
}, [editorModel, /* ... */]);
// ↑ Re-runs on every `editorModel` change
// But how do you express "re-run it only when `editorModel.variables[...].value`s change"?

‍

We’re working on solving these extra renders (e.g., by moving logic away from useEffects). In our tests, this should cut another 10-30% off the JavaScript cost. But this will take some time.

To be fair, React is also working on an auto-memoizing compiler which should reduce recalculations.

<span class="inline-code">useSelector</span> vs. <span class="inline-code">useStore</span>

If you have some data in a Redux store, and you want to access that data in an <span class="inline-code">onChange</span> callback, how would you do that?

Here’s the most straightforward way:

const CellWrapper = () => {
  const editorModel = useSelector(state => state.editorModel)
  const onChange = () => {
    // Do something with editorModel
  }

  return 
}

‍

If <span class="inline-code">Cell</span> is expensive to rerender, and you want to avoid rerendering it unnecessarily, you might wrap onChange with <span class="inline-code">useCallback</span>

const CellWrapper = () => {
  const editorModel = useSelector(state => state.editorModel)
  const onChange = useCallback(() => {
    // Do something with editorModel
  }, [editorModel])

  return 
}

‍

However, what will happen if <span class="inline-code">editorModel</span> changes very often? Right – <span class="inline-code">useCallback</span> will regenerate <span class="inline-code">onChange</span> whenever <span class="inline-code">editorModel</span> changes, and <span class="inline-code">Cell</span> will rerender every time.

Here’s an alternative approach that doesn’t have this issue:

const CellWrapper = () => {
  const store = useStore()
  const onChange = useCallback(() => {
    const editorModel = store.getState().editorModel
    // Do something with editorModel
  }, [store])

  return 
}

‍

This approach relies on Redux’s <span class="inline-code">useStore(</span>) hook.

  • Unlike <span class="inline-code">useSelector()</span>, <span class="inline-code">useStore()</span> returns the full store object
  • Also, unlike <span class="inline-code">useSelector()</span>, <span class="inline-code">useStore()</span> can’t trigger a component render. But we don’t need to, either! The component output doesn’t rely on the <span class="inline-code">EditorModel</span>state. Only the <span class="inline-code">onChange</span> callback needs it – and we can safely delay the editorModel read until then.

Causal has a bunch of components using <span class="inline-code">useCallback</span> and <span class="inline-code">useSelector</span> like above. They would benefit from this optimization, so we’re gradually implementing it. We didn’t see any immediate improvements in the interaction we were optimizing, but we expect this to reduce rerenders in a few other places.

In the future, wrapping the callback with <span class="inline-code">useEvent</span> instead of <span class="inline-code">useCallback</span> might help solve this issue too.

What Didn't Work

Here’s another bit of the performance trace:

In this part of the trace, we receive a new binary-encoded model from the server and parse it using protobuf. This is a self-contained operation (you call a single function, and it returns 400-800 ms later), and it doesn’t need to access DOM. This makes it a perfect candidate for a Web Worker.

Web What? Web Workers are a way to run some expensive JavaScript in a separate thread. This allows keeping the page responsive while that JavaScript is running.

The easiest way to move a function into a web worker is to wrap it with comlink:

import * as eval_pb from "causal-common/eval/proto/generated/eval_pb";
// ...
const response = eval_pb.Response.decode(res);

↓

// worker.ts
import * as Comlink from "comlink";
import * as eval_pb from "causal-common/eval/proto/generated/eval_pb";

const parseResponse = (res) => eval_pb.Response.decode(res);

Comlink.expose({
  parseResponse
});
// index.ts
import * as Comlink from "comlink";

const worker = Comlink.wrap(new Worker(new URL('./worker.ts', import.meta.url)))
// ...
const response = await worker.parseResponse(res);

‍

This relies on webpack 5’s built-in worker support

If we do that and record a new trace, we’ll discover that parsing was successfully moved to the worker thread:

However, weirdly, the overall JS cost will increase. If we investigate this, we’ll discover that we now have two additional flat 400-1200ms long chunks of JavaScript:

Worker thread.
Main thread.

It turns out that moving stuff to a web worker isn’t free. Whenever you pass data from and to a web worker, the browser has to serialize and deserialize it. Typically, this is cheap; however, for large objects, this may take some time. For us, because our model is large, this takes a lot of time. Serializing and deserializing end up longer than the actual parsing operation!

Unfortunately, this optimization didn’t work for us. Instead, as an experiment, we’re currently working on selective data loading (fetching only visible rows instead of the whole model). It dramatically reduces the parsing cost:

With selective data loading, the parsing costs go down from 500-1500 ms to 1-5.

Results

So, how much did these optimizations help? In our test model with ~100 categories, implementing the optimizations (and enabling selective data loading) reduces the JavaScript cost by almost four times đŸ€Ż

Before (median run out of 5).
After (median run out of 5).

With these optimizations, updating category cells becomes a much smoother experience:

We still have chunks of yellow/red in the recording, but they’re much smaller – and intertwined with blue!

We’ve managed to dramatically lower the interaction responsiveness with just a few precise changes – but we’re still far from done. Thanks to Ivan from 3Perf for helping us with this investigation. If you’re interested in helping us get to sub-100ms interaction times, you should consider joining the Causal team – email lukas@causal.app!

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.