DEV Community

Cover image for How I optimize my React Flow application
TuanNQ
TuanNQ

Posted on

How I optimize my React Flow application

I recently built an application to help visualize the structure of each API defined in an OpenAPI file. It renders a nice graph to show how all the reference objects in an API connects to each other, making it easier to understand complex APIs at a glance.

Screenshot introduction of my app

You can try out the application here.

While I built this application as a side project to support my work at my company, I ran into a few interesting performance challenges. To solve those challenges, I went from grabbing the tool which all React developer would think about first like memo to the more advanced yieldToMainThread to learning how browser render a frame and then went back to React with useDeferredValue.

My journey...

I want to share with you three of my optimizations and one of my still unsolved challenge:

  1. Memoizing the React Flow node
    Prevents unnecessary component re-renders, especially when dealing with a lot of complex nodes.

  2. Yielding to the main thread
    I initially tried using a worker thread, but it... actually make the performance worse. Instead, I yield control back to the main thread to avoid blocking the UI.

  3. Using useDeferredValue
    This React hook helped break up complex updates and delay rendering less urgent updates.

  4. Unsolved problem: defer an already deferred component
    I can't find a way to make React not rendering all the left over heavy components all at once.

The problem

When running my application on my low-spec machine, I noticed something odd: the CSS transitions weren’t consistently smooth. Sometimes the item on the left sidebar transition quite smoothly, but sometimes it would cut in suddenly with no animation. The FPS drops and the transition feel abrupt.

Unoptimized version of my app
Notice the item I click on the left sidebar

I don't know if you can see it but the item on the left sidebar feel very unresponsive. There's a noticeable delay of about half a second when I click on it before the background turns blue to indicate selection. The ripple effect is choppy and sometimes does not even run at all!

Here's how it should be:
Optimized version of my app

When I click on an item, it responds almost instantly. The CSS transition is smooth, and the ripple effect runs seamlessly.

Memoize the React Flow node component

I turned on react-scan and it gave me the first clue: the PropertyItem component was re-rendering way too much - around 3 times more than it should be.

PropertyItem component rendering too much

The PropertyItem component somehow renders a total of 380x times!

The PropertyItem component is responsible for each property of an object as you can see below.

PropertyItem component

Though this particular API is quite complex and has a lot of reference objects, it's definitely didn't have a total of 380 properties!

Why 130 of you's render ~380 times???

It turns out that PropertyItem component was re-rendered about 3 times more than necessary because its parent, the ComponentViewer component, was re-rendered 3 times.

react scan screenshot

As the ComponentViewer component is one of my React Flow node component, it led me to the idea of memoize it. Turns out, it's mentioned right there in the React Flow performance docs that you should memoize the React Flow node component to avoid unnecessary renders. I hadn’t done that!

React flow documentation recommend memoize screeshot

I memoized my React Flow node component - the frame rate improved, and switching between APIs felt somewhat smoother.

After I use memo

First rule of optimization: do less.

After the change, react-scan no longer complained about excessive component re-renders. But now, it complained about the Javascript/React hooks execution time took too long.

react scan complains about Javascript/React hooks taking too long

What does that means?

The hint is that while when switching between API's that have small graph, the CSS transitions is quite smooth. But with more complex graph APIs, the CSS transition was still noticeably sudden and the frame rate drop. So the warning means that my calculation to rendering a complex graph took too long.

Screenshot of how complex a graph can be
This is one hell of a complex graph

This time, I can't find a way to shorten the long Javascript execution. To rendering a graph for a complex API, there are calculating-heavy steps needs to be taken.

But why does the heavy calculation to rendering a graph affect a totally non-related item on the left sidebar?

But theses things are completely unrelated to each other...

Yield to the main thread

It turns out that the CSS transition share the same thread with my Javascript. That means the browser can only do one thing at a time - either the CSS transition or my Javascript, but not both simultaneously.

So when my JavaScript blocks the main thread for too long (like rendering a huge graph of complex API structure), the browser has no time left to render the CSS transition - and it ends up looking janky or skipped entirely.

Long Javascript process interrupt CSS transition
The long Javascript execution in the middle make the CSS transitions at the end feel sudden

While I can't do less, I can tell the browser to pause my Javascript in the middle for a few times, run a little bit of that CSS transition, and then come back.

Breaking up long Javascript process

This will make the CSS transitions feel much more responsive and smoothly.

There're a few different ways to break up long task in Javascript, one of them is yieldToMainThread. I use it in the heavy for loop and in recursion function like this:

for (const node of nodes) {
  ...
  await yieldToMainThread();
}
Enter fullscreen mode Exit fullscreen mode
  const addNodeAndEdge = async (component: ComponentNode | PathNode) => {
    await yieldToMainThread();
    ...
    addNodeAndEdge(children)
    ...
  }
Enter fullscreen mode Exit fullscreen mode

Each time that long for loop or the heavy recursion function runs, the browser can pause the JavaScript execution briefly at my yieldToMainThread to run the CSS animation, then resume processing.

Break up using yieldToMainThread

Now the CSS animation now feel just as smooth as if the browser don't have to do gargantuan amount of calculation to render that huge complex graph anymore.

My optimized version

If you can't do less, try smarter scheduling.

What about worker thread?

If my Javascript took too long to run, should I run it in a separate thread? That way, the CSS transition can still smoothly run in the main thread while the worker thread do the heavy lifting.

Using worker thread idea

I tried that and surprisingly, the result is... worse. But why is that?

It's because I can't offload the whole process to the main thread - I can only offload a small part of it. Here's the detailed explanation.

To render a graph of an API, there are 4 steps:

  • Based on the API schema, create nodes and edges in React Flow
  • Render those nodes to measure their width and height.
  • Use DagreJS to calculate the position of each node based on its width and height
  • Render those nodes again in correct position.

4 steps to render diagram

Because I need to render all the nodes first to measure their width and height, I can’t offload the entire calculation process into a worker thread. DOM rendering need to stay in the main thread.

Instead, only offloading the layout calculation (step 3), where DagreJS computes each node’s position, made sense.

Only offload step 3 makes sense...

Ironically, most of the heavy-calculating steps need to stay in the main thread

But create a new thread, sending and receiving messages between threads do have a cost. You need to serialize the message (convert from Javascript object to string) to send to another thread, and to receive a message, you need to deserialize it (convert from string to Javascript object).

With only 20-30 nodes, the node's positioning calculation is so fast that it’s actually better to do it on the main thread than to pay the overhead of messaging between threads.

It's not a good idea...

But this is not the only reason to not use worker thread.

The glorious flashing second

If I use worker thread, you'll see a flash second where the nodes are rendered in wrong position like so:

Weird flash

In fact, I can't even make the calculate node's position step asynchronous (else you'll see that flash second) - let alone offload it into another thread!

Before diving into why, first let’s understand how the browser renders a frame.

To render a frame, the browser goes through four main steps:

  • Style - Apply CSS rules to each element.
  • Layout - Calculate the size and position of each element in the DOM.
  • Paint - Convert elements into actual pixels on the screen.
  • Compositing - (if needed) Layer elements correctly, especially for things like animations, z-index, and transforms.

Browser 4 steps to render a frame

4 steps to render a frame diagram
Here's the official documentation

Earlier where I said React Flow render all nodes to calculate each of its width and height, I... simplified it a bit. React Flow does not actually render all the nodes to the screen to measure their size.

If the browser complete the paint and compositing step before React Flow measuring those nodes, users will see a flash second where all the nodes are in wrong position.

If React flow does not intercept browser in the layout step...

Instead, React Flow lets the browser complete up until the layout step - calculate size of each nodes. Then, just before the paint step, React Flow intercepts the process and gives me access to each node’s dimensions.

React flow stop browser at layout step

Now I can compute the correct position for every node.

Now I calculate the nodes' position

Once that's done, I then make the browser render again with correct position of each nodes. That way, the users will not see a flash second where all the nodes in wrong position.

I give back the correct position to the browser to rerender

When I make the calculating each node's position step asynchronous or offload to another thread, now suddenly the main thread is free again. The browser will use that free time to render all the nodes their wrong position - and that's why you see that flash second.

Why user see the flash second

Sometimes you came up with an "optimization" that performs even worse.

Bonus: if you want React to pause the browser right before the paint process, you can use useLayoutEffect - which has an excellent article about it here

Conclusion

That's it for part 1, in part 2 I'll talk about how the laggy CSS transition in the left sidebar come back and how I use useDeferredValue to deal with that.

Have a nice day!

Credits

If you like the cute fish that I'm using, check out: https://thenounproject.com/browse/collection-icon/stripe-emotions-106667/.

Cute fish

Oh boy I love those fishes!

Top comments (0)