When you optimise your web app, your goal is to make the experience better for the user; this means usually ‘faster’ by transferring and parsing less data. But caution: the same web app can cause Cumulative Layout Shift (CLS) on slower connections but runs without CLS on faster connections.
If you’d like a refresher about Core Web Vitals, I explained them with GIFs in this post.
TL;DR: slower connections can result in CLS when lazy loading components that you wouldn’t see on wifi connections.
Either don’t lazy load the component at all or wait for the js file to be loaded and mounted.
We assume that a web app loads the same on slower connections, just slower. Unfortunately, that’s not always the case with lazy loadable components.
With lazy loadable components we deal with two asyncnesses:
What if the API responses (1) are faster than the dynamically loaded JS (2)? What if you lazy load a component that sits in the middle of your web content? The answer to these questions you see in the screen capture above: Google will punish you with CLS.
I’ve seen a lot of confusion about Core Web Vitals, especially CLS.
Unlike other Core Web Vitals, CLS is continuously measured and cumulatively added to the score. For a classic SPA web app this means that Google will keep the CLS score on a per-route basis.
CLS has the following characteristics:
Measurements by real users: Chrome users send Core Web Vitals metrics to Google directly. It’s not a Googlebot that captures these metrics while crawling the site.
These real user measurements are collected as Field Data and flow into Google’s CrUX report.
That means you need to take the real world into account:
We need to be in full control of what to display to the user at what time. With duality in mind, we need to know the following:
A skeleton loader is an ideal way to wait until both, API requests and async components, are ready.
The quickest and least error prone solution would be to pass on lazy loading components. In most cases the saved kilobytes through lazy loading doesn’t justify the CLS that it might cause. If your performance budget allows it, go with this solution.
Let’s assume we have a Vue web app with 10% logged-in users.
function render() {
// not all users require to download and render HugeComponent
if (isLoggedIn) {
return <HugeComponent />;
}
}
Without code splitting we’d send the JS of <HugeComponent>
to 90% of the users that don’t need it. This can affect LCP and FID.
With code splitting we’d pack the JS of <HugeComponent>
into the additional-comps.js chunk and only send it over the wire when it’s needed.
// without code splitting
import HugeComponent from '@/components/HugeComponent';
// with code splitting
const HugeComponent = () => (await import(
/* webpackChunkName: "additional-comps" */
'@/components/HugeComponent')
).default;
There are two criteria that help you decide if you should lazy load a component or not:
<HugeComponent>
<HugeComponent>
would be rendered for users
If you come to the conclusion that your performance budget is tight and you need to lazy load the component, see solution #2.
If your component isn’t used for the majority of users and it would increase the bundle by a lot, have a look at this solution.
Wait for async components to be lazily loaded and mounted can be tricky. You need to render the component but the mounting happens later. Here’s a gist of how it could be done.
function Parent({ isLoggedIn }) {
const [isLoading, setLoading] = useState(false);
// important: only hide the children with display: none.
// if we used if-else, we'd never load the lazy loadable component;
// and then we'd never change isLoading with its callback.
const styles = { display: isLoading ? 'none' : 'block' };
return (
{/* HugeComponent is lazy loaded, because not all users will need it */}
(isLoggedIn && <HugeComponent
style={ styles }
mounted={() => setLoading(true)}
/>)
{/* display skeleton element for time of loading */}
(isLoading && <div className="skeleton-loader"></div>)
);
}
function HugeComponent(props: { mounted: () => void }) {
const [dataA, setData] = useState(null);
useEffect(async () => {
const result = await lib.request();
setData(result);
// tell the parent component that everything is ready to be fully rendered
props.mounted();
}, []);
return (/* ... */);
}
If we didn’t use display: hidden
for the loading state on the <HugeComponent>
, we’d never trigger the loading of the async component. Thus, Line 28 would never be reached and the isLoading
state would stay on false
forever.
When you lost green URLs in the Google Search Console due to CLS and you can’t reproduce it yourself, try debugging your web app with a slower connection.
So if you’re using lazy loadable components, chances are high that might be a victim of the duality of CLS.
If you found this post interesting please leave a ❤️ on this tweet and consider following my 🎢 journey about #webperf, #nonfiction books, #buildinpublic, and #frontend matters on Twitter.