Google Lighthouse

Optimizing Your Website for Lighthouse v6.0

New features and optimizations are available in React Storefront v7, which cut the the browser bundle size in half and can boost your Lighthouse v6.0 score.

Moovweb 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:  

For the complete analysis of the IR500 domain Lighthouse v6.0 scores, go here!
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.  

Lighthouse 5.7 Weight Lighthouse 6.0 Weight
First Contentful Paint (FCP) 20% First Contentful Paint (FCP) 15%
Speed Index (SI) 27% Speed Index (SI) 15%
First Meaningful Paint (FMP) 7% Largest Contenful Paint (LCP) 25%
First CPU Idle (FCI) 13% Total Blocking Time (TBT) 15%
Time to Interactive (TTI) 33% Time to Interactive (TTI) 15%
- - Cumulative Layout Shift (CLS) 5%

Total Blocking Time (TBT)

Total blocking time is a new metric included in Lighthouse v6.0 that measures the amount of time that parsing and execution of JavaScript blocks the main thread during page load. This metric treats modern, JavaScript-heavy SPAs/PWAs very harshly. Even with React Storefront 7’s 50% cut in bundle size, we saw a 20 to 30 point dip in Lighthouse v6.0 performance score for product pages, largely due to the inclusion of TBT as a metric that influences 25% of the overall performance score.

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.  

Optimizing LCP

React Storefront’s main product image (merely a placeholder - the green box with “Product 1” in the center) was rendered as an  HTML img tag with a src URL that points to https://via.placeholder.com. That site has a decent TTFB (about 150ms), that could be improved by moving to a faster CDN like Moovweb’s CDN-as-JavaScript. Still, we doubted that 150ms accounted for the nearly 3 second gap between FCP and LCP. To eliminate the gap, we inlined the image using a base64 data URL like this one:

<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAlgAAA…"/>

We did this by downloading the main image, converting it to base64, and stuffing it in the src attribute during server-side rendering on the Moovweb Serverless JavaScript cloud. Here’s the line in the product API endpoint:

const mainProductImage = result.product.media.full[0]
mainProductImage.src = await getBase64ForImage(mainProductImage.src)

And here’s the code that fetches and converts the image to base 64:

import fetch from 'node-fetch'

export default async function getBase64ForImage(src) {
  const res = await fetch(src)
  const contentType = res.headers.get('content-type')
  const buffer = await res.buffer()
  return `data:${contentType};base64,${buffer.toString('base64')}`
}

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.

Optimizing TBT

Next we tried to improve TBT. This was quite challenging. As mentioned previously, to improve LCP, you either need to reduce the size of your JavaScript bundle or improve hydration time.  With most apps it’s simply not feasible to start ripping out dependencies to make the bundle smaller. Apps built with React Storefront 7, already benefit from many compile-time optimizations that minimize bundle size provided by Next.js’s webpack configuration, as well as specific babel optimizations for Material UI. So where could we improve? Hydration time.

Lazy Hydration for the win!

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:

import LazyHydrate from 'react-storefront/LazyHydrate'

<LazyHydrate id="carousel" on="touch">
  <MediaCarousel
    ...
  />
</LazyHydrate>

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.

Optimizing FCP

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:

<script src="/_next/static/runtime/main-cb5fd281935517c43f30.js" async=""></script>

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:

import React from 'react'
import { NextScript as OriginalNextScript } from 'next/document'

/**
 * A replacement for NextScript from `next/document` that gives you greater control over how script elements are rendered.
 * This should be used in the body of `pages/_document.js` in place of `NextScript`.
 */
export default class NextScript extends OriginalNextScript {
  static propTypes = {
    /**
     * Set to `defer` to use `defer` instead of `async` when rendering script elements.
     */
    mode: PropTypes.oneOf(['async', 'defer']),
  }

  static defaultProps = {
    mode: 'async',
  }

  getScripts() {
    return super.getScripts().map(script => {
      return React.cloneElement(script, {
        key: script.props.src,
        defer: this.props.mode === 'defer' ? true : undefined,
        async: this.props.mode === 'async' ? true : undefined,
      })
    })
  }
}

Then we added the following to pages/_document.js:

<NextScript mode="defer" />

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.

Summing up

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

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.

Chunking

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:

const webpack = require('webpack')
const withReactStorefront = require('react-storefront/plugins/withReactStorefront')

module.exports = withReactStorefront({
  target: 'serverless',
  webpack: config => {
    config.plugins.push(
      new webpack.optimize.LimitChunkCountPlugin({
        maxChunks: 1,
      })
    )
    return config
  },
})

Web fonts

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.  

As with analytics, stakeholders seem willing to trade off performance to have a specific font.  React Storefront serves the web font from the same origin as the site itself to eliminate the TLS negotiation time you’d incur if you loaded the font from a third-party CDN, such as Google Fonts. Specifically, we use the typeface-roboto package from NPM. When combined with Webpack’s  css-loader , using typeface-roboto results in the font being imported via a separate css file which the browser needs to download and parse. We thought that inlining that CSS into the document might help with performance, but it didn’t.  

When it comes to performance, you always need to measure. Optimizations that should work in theory may not in practice.

Boost your Lighthouse v6.0 score

Our customers are ranking in the 95th percentile of the top 500 eCommerce websites on Lighthouse v6.0.

Schedule a consultative conversation!

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.
Newsletter
Get great insight from our expert team.
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.
By signing up you agree to our Terms & Conditions

Don't wait another second. Go instant.

Get started in seconds