Michal Matuška is a web performance consultant and JavaScript frameworks optimisation whiz. He helps clients boost conversions by speeding up their websites, contributes to the development of a smart speed monitoring tool and co-creates the web performance newsletter at SpeedJar.com.
In this post, let’s consider several optimization techniques for improving Core Web Vitals metrics for sites that are built with React.
We are a team of speed consultants from the Czech Republic and in this article we would like to share some experiences from the many front-end performance optimizations we did for our clients.
We will mainly focus on the Interaction to Next Paint (INP) metric, i.e. the response rate to interactions. Optimizing the speed of sites built on React involves optimizing JavaScript long tasks. They are closely related to how React works internally.
Is the web on React automatically fast? Wrong!
Internally, React uses several clever techniques to help the speed of websites or web applications.
It efficiently updates and renders only the components in which the data changes. The hook system in turn partially protects the site from unwanted layout recalculations and so-called layout thrashing.
Does this mean that a website built on React will automatically be fast? This is a common opinion among developers. The answer is simple: even websites built on React need to be optimized.
This can also be seen in the data from the HTTP Archive:
Data shows that sites built on React meet Core Web Vitals metrics less often than sites built on PHP.
While declarative components in React make code more predictable and easier to understand, careless manipulation of the state or number of components almost certainly leads to slow interactivity.
React is a tool like any other and it always depends on how well the developer knows it.
Let’s now move on to the React optimization tips we recommend based on our work. How to keep React code under control?
1) Reduce the DOM size
Sizing the DOM and optimizing it is a fundamental requirement. If the DOM is too large (has too many elements or deep nesting), it can degrade performance, slow down rendering and increase memory load.
In React, this recommendation is doubly true. Fewer elements means fewer components, which means less JavaScript to download and process.
The DOM size can be quickly verified via the Google Chrome console. Just type in the document.querySelectorAll("*").length
script and you know where you stand.
The console lists the number of DOM elements found as 5,132. That’s quite a lot.
Google recommends 1,400 elements as the maximum for DOM. This is quite strict, especially for larger sites like e-commerce or apps. In our experience, 2,500 DOM elements are still handled quite briskly by the browser. Once the number of DOM elements exceeds this limit, things get complicated quickly.
What about the DOM size? The most effective is to delete and lazily load
What is not in the DOM does not need to be rendered and the browser can rest. Therefore, first focus on components that are large and not important for SEO. Remove them from the DOM and load them using lazy loading:
import { lazy } from 'react'; const MarkdownPreview = lazy(() => import('./MarkdownPreview.js'));
To maximize the effect, load such a component when the user needs it or it appears in the viewport. Typical components are: maps, charts, visualizations, WYSIWYG but also forms and filtering, all outside the first visible viewport.
Simplify the component structure
A lot of components are often created in the UI. With today’s modern HTML and CSS capabilities, we need fewer and fewer wrapper elements that perform only a layout role.
A typical example of waste is the star rating and the unnecessary extension of the DOM with separate “stars”:
// Bad <StarRating> <SVGStar/> <SVGStar/> <SVGStar/> <SVGStar/> <SVGStar/> </StarRating>
A similar thing could be solved by a single element with a width and repeating background.
There are a number of techniques to limit the size of the DOM
There are other techniques to effectively get rid of a lot of rendered components. Worth mentioning is definitely the virtual scroll for large lists.
SSR always helps
When using SSR (Server Side Rendering), the time needed to build the first HTML response is optimized. It also has a smaller data size. Simply a win-win on all sides.
You should always think about the priority of your components and the efficiency of your HTML constructs.
Check out other ways to efficiently resolve large DOM volume.
2) Divide the components into simple and extended versions
Again, this recommendation is nothing more than deleting DOM elements. But it is a bit different. It fundamentally changes the paradigm of how you look at the structure of HTML and DOM today.
Even if a component, or its content, is important for SEO or accessibility, that doesn’t mean it has to be at full visual quality when first rendered. Especially if the component is not visible in the first viewport.
How many components will the user actually see? Some of them are hidden behind the interaction e.g. megamenu or modal window, other components can be seen after scrolling. Does it really need all components in HTML in final form?
Optimizing by splitting into simple and rich components is especially effective for elements that are repeated multiple times on a page. These are typically landing pages, product listings, or other offerings like you see in the image:
An example of a component that provides only SEO-relevant data when the page is loaded. This state is visually hidden from the user. The “Rich variant” is activated by displaying the component in the viewport.
To illustrate, the following code sample shows how to use Intersection Observer to load a richer version of the component:
import React from "react"; import { useInView } from "react-intersection-observer"; const Offer = ({images, title}) => { const { ref, inView, entry } = useInView(); return ( <article className="offer" ref={ref}> <div className="gallery"> {!inView ? <Image data={images[0]}> : <ImagesCarousel data={images} />} <h3>{title}<h3> </div> </article> ); };
When you compare the important content and the resulting HTML, you will find that the skeleton of DOM can be quite simple. The visual richness can be added to the frontend during the user’s visit.
However, always beware of layout stability to avoid messing up the CLS metrics during rendering optimization.
Server Components can help here
Some of the problems mentioned in this section are solved by the relatively new React Server Components, which allow you to write components that are not available in client JavaScript, but only on the server.
In this case, the browser receives the already rendered content and does not need to re-run JavaScript to display or animate the content. Even so, keep the DOM structure as small as possible. Our recommendation optimizes the actual rendering of the UI in the browser.
3) Use <Suspense>
OK, we have an optimized DOM. This will often make a substantial improvement in the INP metric. Let’s consider whether the workload itself can be further decomposed over time.
The <Suspense>
tag is primarily used in React to display placeholder content during the loading of different components.
However, few people know that the hidden ability of <Suspense>
is that it turns on selective rendering. This allows the page and its components to be divided into important and less important parts.
The component tree is split using the <Suspense>
tag. Red components are marked as less important by this tag.
When using SSR (Server Side Rendering), the effect of the <Suspense>
tag is absolutely crucial.
Hydration is the moment when the server-side generated code is revived on the client side of the browser. Server delivers HTML that is quickly visible to the user, and React then adds interactivity. Without using <Suspense>
, it’s always one long javascript task.
Split the page into separate logical units and wrap them in their own <Suspense>
tag.
The booking.com website divided into separate logical units.
But watch out! Using the tag can also be counterproductive. Should the user quickly perform an action with an element that is inside <Suspense>
, React must switch focus and process the entire block. Otherwise, it wouldn’t know exactly what the user did. This leads to synchronous processing and thus prolonging the whole event.
So never wrap a large block with this tag, rather just smaller parts – sections. At the same time, don’t use <Suspense>
for the elements that are visible in the first viewport.
4) Watch out for hydration errors
We have already explained what hydration is. But we haven’t mentioned one important thing. At the end of the hydration, a validation is performed that compares the resulting element tree (DOM) with the state that came from the server. The HTML must match exactly what React expects on the client side.
If there are differences between these versions, React throws an error in the browser console:
Display hydration error in the browser console.
At that point, React may invalidate all or most of the components on the page and trigger their update by client JavaScript, which degrades performance by extending the hydration phase and can annoyingly delay the LCP metrics.
Worse, you can get into this error quite easily. For example, using Math.random()
or Date.now()
directly when rendering on the server and client can cause the content to not be identical.
Another part of the bug is the conditional use of APIs that are only available in the browser.
// Bad function LanguageComponent() { const language = window?.navigator?.language ?? "en"; return <h1>Your language is: {language}</h1>; }
In this case, you need to use the useEffect
function. This will complicate the code a bit, but it will handle the error.
// Good function LanguageComponent() { const [language, setLanguage] = useState("en"); useEffect(() => { // Update language using client-side API setLanguage(window.navigator.language); }, []); return <h1>Your language is: {language}</h1>; }
5) Watch out for useEffect()
useEffect
in React is a special function that does one thing. React to changes in the component or its state.
import React, { useState, useEffect } from 'react'; function Counter() { const [count, setCount] = useState(0); // useEffect watches changes in 'count' and reacts to them useEffect(() => { console.log(`The count has been updated to: ${count}`); }, [count]); // Watching only 'count' changes return ( <div> <h1>Click count: {count}</h1> <button onClick={() => setCount(count + 1)}>Click me</button> </div> ); } export default Counter;
By definition, this is a function that is called after the change occurs. It is generally ingrained in the minds of React developers that the function is even called after the HTML is rendered.
Unfortunately, this is not true. useEffect
is not always asynchronous. When the user invokes an input (for example, clicks), all React code is executed synchronously, including “effect hooks”.
If the hook is used to actually postpone the job until the next render cycle, you must use setTimeout
or another method.
useEffect(() => { // Defer work to a separate task: setTimeout(() => { sendAnalytics(); }, 0); }, []);
Beware of any analytic code.
Conclusion
I hope I’ve helped you with specific ways to optimize the performance of sites built on React, with an emphasis on the INP metric.
Basically, it’s simple – watch out for a big DOM, defer what you can, and take special care with the hydration process.
React is just a tool. The key is to know it well. In this regard, we highly recommend the React Internals Deep Dive series of articles. Check out other methods for optimizing INPs too.