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.
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.
I want to share with you three of my optimizations and one of my still unsolved challenge:
Memoizing the React Flow node
Prevents unnecessary component re-renders, especially when dealing with a lot of complex nodes.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.Using
useDeferredValue
This React hook helped break up complex updates and delay rendering less urgent updates.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.

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!
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.
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.
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!
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.
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!
I memoized my React Flow node component - the frame rate improved, and switching between APIs felt somewhat smoother.
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.
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.

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?
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.

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.
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();
}
  const addNodeAndEdge = async (component: ComponentNode | PathNode) => {
    await yieldToMainThread();
    ...
    addNodeAndEdge(children)
    ...
  }
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.
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.
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.
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.
 
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.
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.
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:
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.
 

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.
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.
Now I can compute the correct position for every node.
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.
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.
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/.
Oh boy I love those fishes!
              

























    
Top comments (0)