Layer0 is a major contributor to the open-source eCommerce PWA framework, React Storefront. Earlier this year, we contributed many new features and optimizations as React Storefront v7, two of the most significant were the shift to Next.js and the removal of several key dependencies (e.g. MobX) in favor of React’s newer built-in capabilities for managing state, such as the useState hook and the context API. These resulted in the browser bundle size being cut roughly in half.
At the time, this was a nice gain and helped bump Lighthouse (v5.7) performance scores for typical React Storefront apps from the 80s into the 90s as measured by PageSpeed Insights (PSI). For context, a score of 83+ outperformed 99% of the top 500 eCommerce websites on Lighthouse v5.7. We didn’t realize how essential the bundle reduction would prove in the coming months, when Lighthouse v6.0 would drop like a bomb and obliterate everyone’s performance scores.
See how the distribution of Lighthouse scores measured on PSI for the leading eCommerce websites changed when v6.0 dropped:
In this post, we share how we improved Lighthouse v6.0 scores for React Storefront, but the techniques can be applied to other frameworks.
Also, it’s important to note that Google announced on May 28th, 2020 the specific metrics they will use to rank sites in 2021. The Lighthouse performance score will not be used, although some elements used to determine that score will, and even then, they won’t be measured using a synthetic test such as Lighthouse, but rather real-world field data from the Chrome User Experience Report (CrUX).
The new metrics in Lighthouse 6.0: TBT, LCP & CLS
Lighthouse v6.0 introduces several new perceptual speed metrics and reformulates how overall metrics affect a page’s Lighthouse performance score.
Total Blocking Time (TBT)
If you’re using an isomorphic framework, like Next.js which supports server-side rendering, TBT is mostly determined by bundle size and hydration time. Put simply, the only way to improve TBT is to remove dependencies, optimize your components, or make your site simpler by using fewer components.
Largest Contentful Paint (LCP)
LCP is a new metric that has a weight of 25% over the overall Lighthouse v6.0 score. LCP aims to quantify how the user perceives initial page load performance by observing how long it takes the largest contentful element to finish painting. For most sites, especially in eCommerce websites, the largest contentful element is the hero image. In the case of product pages in React Storefront apps, the largest contentful element is the main product image. If you’re unsure which element this is on your site, PSI will tell you:
To optimize for LCP, you need to make sure that image loads as quickly as possible.
Cumulative Layout Shift (CLS)
Cumulative layout shift measures how much the page layout shifts during initial page load. Layout shift is most commonly caused by images, which tend to push the elements around them as they resize to accommodate the image once data is downloaded from the network. Layout shift can often be fully eliminated by reserving space for each image before it loads. Fortunately React Storefront’s Image component already does this, so the React Storefront starter app boasts a perfect CLS score of 0 out of the box.
It should be noted that other common culprits of poor CLS are banners and popups that appear after the page is initially painted. Users hate them and now Lighthouse does too.
How we optimized React Storefront for Lighthouse v6.0
When we first tested the React Storefront starter app’s product page on Lighthouse v6.0, using PageSpeed Insights, it scored in the low 60s:
To improve the score, we first set our sights on LCP. At 2.5 seconds, FCP was worryingly high (we’ll get to that later), but the nearly 3 second gap between FCP and LCP really stood out as something that needed improvement.
And here’s the code that fetches and converts the image to base 64:
Pretty simple and old skool. And the impact on the score?
By dropping the LCP from 5.3s to 2.8s, we gained 21 points in the page’s Lighthouse v6.0 score! It’s a bit unsettling how such a small change can make such a dramatic difference in Lighthouse v6.0 score, but we’ll take it. It should be noted that all of the metrics vary somewhat between runs, but the overall score was consistently in the low 80s. For context, the highest performing leading eCommerce website on v6.0 scores 87 as measured on PSI and looks like it’s straight out of the 90s- take a look www.rockauto.com
The gap between FCP and LCP shown above was about as large as we saw it across several runs. Most times the gap was in the 100ms to 300ms range. Occasionally FCP and LCP were the same.
Fortunately, the React Storefront community had already begun work on supporting lazy hydration before Lighthouse v6.0 dropped. This certainly made us accelerate our efforts.
In case you’re unaware, hydration refers to React taking control of HTML elements that were rendered on the server so that they can become interactive. Buttons become clickable, carousels become swipeable, etc. The more components a page has, the longer hydration takes. Complex components, such as the main menu and the product image carousel, take even longer.
Lazy hydration entails delaying the hydration of certain components until it is absolutely necessary, and most importantly, after the initial page load (and after TBT is calculated). Lazy hydration can be risky. You need to make sure that page elements are ready to respond to user input before the user attempts to interact with them.
Implementing lazy hydration on React Storefront proved quite difficult due to Material UI’s reliance on CSS-in-JS, which dynamically adds styles to the document only after components are hydrated. I’ll save the details for another time. In the end we built a LazyHydrate component that developers can insert anywhere in the component tree to delay hydration until a specific event occurs, such as the element being scrolled into the viewport or the user touching the screen.
Here’s an example where we lazy hydrate the MediaCarousel that displays the main product images:
We applied lazy hydration to several areas of the application, most notably:
- The slide-in menu: we hydrate this when the user taps the hamburger button.
- All of the controls below the fold: these include the size, color, and quantity selectors, as well as product information tabs.
- The main image carousel: this and the main menu are probably the components with the most functionality and therefore the most expensive to hydrate.
Here is the Lighthouse v6.0 score with lazy hydration applied:
Lazy hydration cut TBT by nearly 40% and trimmed TTI (which has a 15% weight over scores in v6.0) by 700ms. This netted a 6 point gain in the overall Lighthouse v6.0 score.
You’ll notice FCP went up a bit, but LCP went down. These are small changes that are essentially “within the noise” you get when running PageSpeed Insights. All the scores fluctuate slightly between runs.
Based on the score above, we felt that FCP and/or LCP might further be improved. We know that scripts can block rendering, so we looked at how Next.js imports scripts into the document:
Using async here might not be the best choice. If the script is downloaded during rendering, it can pause rendering while the script is evaluated, which increases both FCP and LCP times. Using defer instead of async would ensure that scripts are only evaluated after the document is painted.
Unfortunately Next.js doesn’t allow you to change how scripts are imported, so we needed to extend the NextScript component as follows:
Then we added the following to pages/_document.js:
To our delight, this did improve the LCP and overall scores:
It also gave a slight bump to the FCP on many runs, but this may be within the “noise.” Nevertheless the overall score was consistently 2-3 points higher when using defer vs async.
When Lighthouse v6.0 was released in late May 2020 the performance scores for many sites plummeted, including React Storefront apps. Before optimizations, the React Storefront starter app’s PDP performance was mired in the low 60s. With these optimizations we got it into the now rarified air of the low 90s. At this point, we think the only way to further improve the score would be to start removing dependencies, which may mean trading off developer productivity for application performance.
That’s a discussion for another time. Let me leave you with some things we tried that didn’t work:
Preact makes the bundle size 20-30% smaller, but Lighthouse v6.0 scores were consistently worse across all metrics, even TTI. We have no idea why, but we know this is not new or exclusive to Lighthouse v6.0. It was slower with Lighthouse v5.7 as well. We continue to check in periodically and hope someday this is fixed.
Next.js recently introduced finer-grained chunking of browser assets. When this was first introduced in Next.js 9.1, we noticed that the additional, smaller chunks actually made TTI worse. It probably makes the app faster for returning users after a new version is released because it can better leverage the browser cache, but Lighthouse doesn’t care about any of that. So React Storefront has limited the number of browser chunks to one for a while:
Most sites use a custom web font. By default, React Storefront uses Roboto (though this can be changed or removed). Custom web fonts kill performance, plain and simple. Remove the web font and you’ll gain about 1 second of FCP.
When it comes to performance, you always need to measure. Optimizations that should work in theory may not in practice.