How we boosted the performance of a Gatsby website by 80%
Tags: javascript, react, gatsby
Published: 2020-07-19 00:00:00

---
title: How we boosted the performance of a Gatsby website by 80%
published: true
date: "2020-10-03"
author: "seif"
tags: javascript, react, gatsby
canonical_url: https://tinloof.com/blog/How-we-boosted-the-performance-of-a-Gatsby-website-by-80/
---

> Article originally published on [Tinloof](https://tinloof.com/blog/How-we-boosted-the-performance-of-a-Gatsby-website-by-80/).

<figure>
  <img loading="lazy"
    src="https://res.cloudinary.com/https-tinloof-com/image/upload/v1594848718/blog/sbai-dentaire-article/iteration-2/1_dbg7bb.png"
    alt="Final perf"
  />
  <figcaption>Final perf</figcaption>
</figure>

[Sbaï Dentaire](https://sbaidentaire.com/) is the number 1 user-rated dental practice in Casablanca (Morocco) by Google users:

<figure>
  <img loading="lazy"
    src="https://res.cloudinary.com/https-tinloof-com/image/upload/v1595180924/blog/sbai-dentaire-article/google-my-business-ranking_vpy0sc.jpg"
    alt="Google Reviews results for Sbaï Dentaire"
  />
  <figcaption>Google Reviews results for Sbaï Dentaire</figcaption>
</figure>

Many patients book appointments through the website on the go from their mobile, therefore page speed is critical to avoid a high bounce rate.

This article explains how we improved the performance of their website, which is built with [Gatsby](https://www.gatsbyjs.org/).

> **Note about Gatsby:** this is just a simple presentation website, it would have made sense to implement it with pure HTML, CSS, and JavaScript. It would avoid the cost of extra JavaScript that comes with Gatsby and React, and the performance should be better than anything we'd try to achieve with Gatsby. <br/>
However, there is a plan to integrate a blog to the website in the future and Gatsby makes it easier.

# Measuring performance

Measuring performance steers our work by identifying potential performance issues and making sure we're progressing in the right direction after making any changes.

## How to measure performance?

To measure the performance, we use Google's Lighthouse tool, which is available out of the box in Chrome:

<figure>
  <img loading="lazy"
    src="https://res.cloudinary.com/https-tinloof-com/image/upload/v1594666139/blog/sbai-dentaire-article/measure/chrome-lighthouse_vddf7t.png"
    alt="Google Lighthouse tool"
  />
  <figcaption>Google Lighthouse tool</figcaption>
</figure>

To keep the test conditions as close to the live version, we make sure of the following:

- The tests are made on the production build.

- We focus on mobile users since they tend to be the ones with the most critical network connection. Having a high performance on mobile is also often followed by an even higher one on desktop.

- The tests are made in Chrome Incognito mode, to avoid getting affected by Chrome Extensions. This is recommended by Lighthouse itself:

<figure>
  <img loading="lazy"
    src="https://res.cloudinary.com/https-tinloof-com/image/upload/v1594917098/blog/sbai-dentaire-article/lighthouse%20warning%20about%20Chrome%20Extensions.png"
    alt="Lighthouse recommendation about Chrome extensions"
  />
  <figcaption>Lighthouse recommendation about Chrome extensions</figcaption>
</figure>

- The *[Simulated Throttling](https://github.com/GoogleChrome/lighthouse/blob/master/docs/throttling.md#devtools-lighthouse-panel-throttling)* option is enabled in Lighthouse. This enables us to simulate a realistic slow network connection:

<figure>
  <img loading="lazy"
    src="https://res.cloudinary.com/https-tinloof-com/image/upload/v1594917638/blog/sbai-dentaire-article/measure/simulated_netowk_throttling_c9uqsv.png"
    alt="Network throttling option"
  />
  <figcaption>Network throttling option</figcaption>
</figure>

- Lighthouse scores vary for each time you run it. To improve the accuracy of our measured progress, we conduct 3 runs per performance test instead of 1.


We also keep track of 3 metrics:
  - **Performance score** (the most important one):  overall performance of the page.
  - **First Contentful Paint**: time (in seconds) it takes for the first element in the DOM to render.
  - **Time to Interactive**: time (in seconds) it takes for the page to fully load and the buttons/inputs to be usable.

## About the current performance

When we run the first 3 tests with Lighthouse, we get the following:

<figure>
  <img loading="lazy"
    src="https://res.cloudinary.com/https-tinloof-com/image/upload/v1594665972/blog/sbai-dentaire-article/iteration-0/1_tdszpu.png"
    alt="Current situation (Test 1)"
  />
  <figcaption>Current situation (Test 1)</figcaption>
</figure>

<figure>
  <img loading="lazy"
    src="https://res.cloudinary.com/https-tinloof-com/image/upload/v1594665974/blog/sbai-dentaire-article/iteration-0/2_gcvah0.png"
    alt="Current situation (Test 2)"
  />
  <figcaption>Current situation (Test 2)</figcaption>
</figure>

<figure>
  <img loading="lazy"
    src="https://res.cloudinary.com/https-tinloof-com/image/upload/v1594665972/blog/sbai-dentaire-article/iteration-0/3_dgsbpg.png"
    alt="Current situation (Test 3)"
  />
  <figcaption>Current situation (Test 3)</figcaption>
</figure>

When we average the metrics in the 3 tests, we get the following:

| Metric                 | Value  |
|------------------------|------- |
| Performance            | 54     | 
| First Contentful Paint | 2.1 s  |
| Time to Interactive    | 10.6 s |

Although the First Contentful Paint time is acceptable, the performance score and the Time to Interactive should definitely be improved.

## Where do we stand against competitors?

We ran the same tests with the other top 4 ranked dental practice websites and gathered the following data:

<figure>
  <img loading="lazy"
    src="https://res.cloudinary.com/https-tinloof-com/image/upload/v1595177378/blog/sbai-dentaire-article/Performance_Metrics_Benchmark_dw4yqf.png"
    alt="Comparing performance with competitors"
  />
  <figcaption>Comparing performance with competitors</figcaption>
</figure>

From what we see in the graph above, Sbaï Dentaire's website is performing well relative to competitors (aside from Competitor C). 

C outperforms Sbaï Dentaire in the overall performance score and in the Time to Interactive. 

This emphasizes the importance of prioritizing these 2 metrics. Nonetheless, we should try to improve the First Contentful Paint if possible.

# Improving the performance of images

One problem we quickly notice is that the website is making huge network requests to fetch images (mostly greater than 600 KB of payload):

<figure>
  <img loading="lazy"
    src="https://res.cloudinary.com/https-tinloof-com/image/upload/v1594805241/blog/sbai-dentaire-article/iteration-0/enormous_network_xjax29.png"
    alt="Enormous image requests"
  />
  <figcaption>Enormous image requests</figcaption>
</figure>

Another issue is spotted in the Network Tab where we see that images are fetched simultaneously at page load:

<figure>
  <img loading="lazy"
    src="https://res.cloudinary.com/https-tinloof-com/image/upload/v1594805701/blog/sbai-dentaire-article/iteration-0/initial-images-network_tiqwap.png"
    alt="Images fetched simultaneously"
  />
  <figcaption>Images fetched simultaneously</figcaption>
</figure>

We can do so by:

1. Using [WebP](https://developers.google.com/speed/webp) format for images for browsers that support it. This format provides an image compression that is way more efficient than png, which shaves a lot of kBs from our images.
2. [Lazy-loading images](https://web.dev/native-lazy-loading/) to only fetch the ones visible in the viewport. This improves the work done by the browser when loading the page for the first time [since a large part of the images won't even be loaded.](https://addyosmani.com/blog/lazy-loading/)
3. Saving on request payload by reducing the requested size on mobile. This technique is known as Art Direction.

We'd normally use [gatsby-image](https://www.gatsbyjs.org/packages/gatsby-image/) to handle the previous issues, but there is a [bug](https://github.com/gatsbyjs/gatsby/issues/16763) in the library affecting Art Direction. Fortunately, we can use the module [gatsby-plugin-sharp](https://www.gatsbyjs.org/packages/gatsby-plugin-sharp/) with the native `<picture>` tag to achieve the same result.

[gatsby-plugin-sharp](https://www.gatsbyjs.org/packages/gatsby-plugin-sharp/) can apply the necessary transformations through [GraphQL](https://graphql.org/) queries. Here's an example query we used to transform `home.png`, the image used in the home section:

```jsx
function getImageSources() {
  const data = useStaticQuery(graphql`
    query {
      mobileImage: file(relativePath: { eq: "home.png" }) {
        childImageSharp {
          fixed(width: 500) {
            ...GatsbyImageSharpFixed_withWebp_noBase64
          }
        }
      }
      desktopImage: file(relativePath: { eq: "home.png" }) {
        childImageSharp {
          fixed(width: 900) {
            ...GatsbyImageSharpFixed_withWebp_noBase64
          }
        }
      }
    }
  `);

  return {
    mobileImage: data.mobileImage.childImageSharp.fixed,
    desktopImage: data.desktopImage.childImageSharp.fixed
  };
}
```

The function `getImageSources` :

- Gets two different sizes for `home.png` (*500px* for mobile and *900px* for desktop).
- Uses `GatsbyImageSharpFixed_withWebp_noBase64` fragment to get the `src` , `srcSet`, `webpSrc`, and `webSrcSet` data for both sizes. These attributes are then used to apply proper Art Direction with a `picture` tag.

We can then use the data to tell the browser to:

- Fetch the mobile image for screens less than *480px* of width.
- Use `webp` format when possible (since not all browsers support it).

To avoid code redundancy, and make sure `loading=lazy` attribute is used with all images, we create an `Image` component to use whenever we want to render images:

```jsx
function Image({ image, desktopImage, styles, alt }) {
  return (
    <picture>
      {desktopImage && (
        <>
          <source media="(min-width: 480px)" srcSet={desktopImage.srcSet} />
          <source
            media="(min-width: 480px)"
            srcSet={desktopImage.srcSetWebp}
            type="image/webp"
          />
        </>
      )}
      <source srcSet={image.srcWebp} type="image/webp" />
      <img
        src={image.src}
        srcSet={image.srcSet}
        alt="Homepage"
        loading="lazy"
        css={styles}
        alt={alt}
      />
    </picture>
  );
}

const imageShape = PropTypes.shape({
  src: PropTypes.string.isRequired,
  srcSet: PropTypes.string,
  srcWebp: PropTypes.string,
  srcSetWebp: PropTypes.string
});

Image.propTypes = {
  image: imageShape.isRequired,
  desktopImage: imageShape
};
```

Here's how the home section component uses it:

```jsx
function Home() {
  const { mobileImage, desktopImage } = getImageSources();

  return (
    <div id="home" css={styles.home}>
      <section css={styles.textContainer}>
        <section>
          <h1>Un beau sourire à Casablanca</h1>
          <p>Assuré par un soin dentaire de qualité depuis 30 ans</p>
        </section>
        <a className="button primary" href="#contact">
          Nous contacter
        </a>
      </section>
      <div css={styles.imageContainer}>
        <Image
          image={mobileImage}
          desktopImage={desktopImage}
          alt="Homepage"
          styles={styles.img}
        />
      </div>
    </div>
  );
}
```

For sections that require fetching a batch of images, we use the `relativeDirectory` filter in the GraphQL query to fetch all images in a certain directory and create a map of `imageId -> imageData` to use when rendering these images.

Here's an example of the query used to fetch images for the Reviews section:

```jsx
 function getImagesMap() {
  const data = useStaticQuery(graphql`
    query {
      allFile(filter: { : { eq: "reviews" } }) {
        nodes {
          childImageSharp {
            fixed(width: 90) {
              ...GatsbyImageSharpFixed_withWebp_noBase64
            }
          }
          name
        }
      }
    }
  `);

  return imagesToMap(data.allFile.nodes);
}
```

And here's the body of `imagesToMap` :

```jsx
function imagesToMap(images) {
  return images.reduce(
    (acc, { name, childImageSharp: { fixed } }) => ({ ...acc, [name]: fixed }),
    {}
  );
}
```

We then iterate through the images data and render them with our `Image` component:

```jsx
function Reviews() {
  const imagesMap = getImagesMap();

	return (
		...
		{data.map(review => (
		  <Image
		    alt={review.name}
		    image={imagesMap[review.image]}
		    styles={styles.img}
		  />
		}
	...
);
```

## Let's check if images are optimized

All images are fetched in `webp` format and their sizes got dramatically reduced. The Network Tab shows the following:

<figure>
  <img loading="lazy"
    src="https://res.cloudinary.com/https-tinloof-com/image/upload/v1594845409/blog/sbai-dentaire-article/iteration-1/webp_images_c4cb33.png"
    alt="Optimized images"
  />
  <figcaption>Optimized images</figcaption>
</figure>

We also see that images are lazy-loaded on scroll whenever they're close to appearing in the viewport:

<figure>
  <img loading="lazy"
    src="https://res.cloudinary.com/https-tinloof-com/video/upload/e_loop/v1594846203/blog/sbai-dentaire-article/iteration-1/lazy-loaded-images-video_g3pb2s.gif"
    alt="Images are lazy-loaded"
  />
  <figcaption>Images are lazy-loaded</figcaption>
</figure>

## Resulting performance

Now that all images issues are fixed, let's run Lighthouse again and check the website's performance:

<figure>
  <img loading="lazy"
    src="https://res.cloudinary.com/https-tinloof-com/image/upload/v1594845408/blog/sbai-dentaire-article/iteration-1/1_jsimlg.png"
    alt="Optimizing images (Test 1)"
  />
  <figcaption>Optimizing images (Test 1)</figcaption>
</figure>

<figure>
  <img loading="lazy"
    src="https://res.cloudinary.com/https-tinloof-com/image/upload/v1594845409/blog/sbai-dentaire-article/iteration-1/2_vxafwm.png"
    alt="Optimizing images (Test 2)"
  />
  <figcaption>Optimizing images (Test 2)</figcaption>
</figure>

<figure>
  <img loading="lazy"
    src="https://res.cloudinary.com/https-tinloof-com/image/upload/v1594845408/blog/sbai-dentaire-article/iteration-1/3_vqimto.png"
    alt="Optimizing images (Test 3)"
  />
  <figcaption>Optimizing images (Test 3)</figcaption>
</figure>

| Metric                 | Initial value | New value | Overall Progress |
|------------------------|---------------|-----------|------------------|
| Performance            | 54            | 63.3      | + 9.3 (+ 17%)    |
| First Contentful Paint | 2.1 s         | 1.8 s     | - 0.3 s          |
| Time to Interactive    | 10.6 s        | 9.2 s     | - 1.4 s          |

We solved the issues with images, which resulted in a noticeable performance improvement:

- The overall Performance improved by 17%.
- The First Contentful Paint is 300 ms faster.
- The Time to Interactive is 1.4 s faster.

# Lazy-loading Google Maps

When we look at Lighthouse reported problems, we find an issue with the Google Maps used in the Contact section:

<figure>
  <img loading="lazy"
    src="https://res.cloudinary.com/https-tinloof-com/image/upload/v1594845409/blog/sbai-dentaire-article/iteration-1/maps-problem_vwalrb.png"
    alt="Google Maps issue"
  />
  <figcaption>Google Maps issue</figcaption>
</figure>

We see 2 problems:

- Google Maps scripts and images are not lazy-loaded.
- Google Maps images are not efficiently compressed since they're using either jpg or png.

Lazy-loading Google Maps when the user scrolls close enough to the Contact section should solve these issues.

To detect when an element (in our case the Contact section) appears in the viewport, we create a `useInViewPort` hook which leverages the power of `IntersectionObserver` to do its job:

```jsx
const THRESHOLD = 0;

export default function useInViewPort() {
  let nodeRef = React.useRef(null);
  let observerRef = React.useRef(null);

  const [isVisible, setIsVisible] = React.useState(false);

  React.useEffect(() => {
    observerRef.current = new IntersectionObserver(
      entries => {
        setIsVisible(entries[0].isIntersecting);
      },
      { THRESHOLD }
    );

    observerRef.current.observe(nodeRef.current);

    return () => {
      observerRef.current.disconnect();
    };
  }, []);

  React.useEffect(() => {
    if (isVisible) {
      observerRef.current.disconnect();
    }
  }, [isVisible]);

  return [nodeRef, isVisible];
}
```

We then use it in the Contact section to lazy-load Google Maps:

```jsx
function Contact() {
  const mapRef = React.useRef();
  const [nodeRef, isVisible] = useInViewport();

  function initialize() {
    new window.google.maps.Map(mapRef.current, mapOptions);
  }

  React.useEffect(() => {
    if (isVisible) {
      const script = document.createElement("script");
      script.src = `https://maps.googleapis.com/maps/api/js?key=${API_KEY}&language=fr`;
      script.addEventListener("load", initialize);
      document.body.appendChild(script);
    }
  }, [isVisible]);

  return (
    <div ref={nodeRef}>
			...
        <section>
          <div ref={mapRef} css={styles.map}></div>
        </section>
			...
    </div>
  );
}
```

## Checking if Google Maps is lazy-loaded

We do so by checking the Network Tab while scrolling:

<figure>
  <img loading="lazy"
    src="https://res.cloudinary.com/https-tinloof-com/video/upload/e_loop/v1594848517/blog/sbai-dentaire-article/iteration-2/lazy-load-map_h9jwyh.gif"
    alt="Google Maps lazy-loaded"
  />
  <figcaption>Google Maps lazy-loaded</figcaption>
</figure>

## Measuring the new performance

Lighthouse gives the following new performance metrics:

<figure>
  <img loading="lazy"
    src="https://res.cloudinary.com/https-tinloof-com/image/upload/v1594848718/blog/sbai-dentaire-article/iteration-2/1_dbg7bb.png"
    alt="Lazy-loading Google Maps (Test 1)"
  />
  <figcaption>Lazy-loading Google Maps (Test 1)</figcaption>
</figure>

<figure>
  <img loading="lazy"
    src="https://res.cloudinary.com/https-tinloof-com/image/upload/v1594848716/blog/sbai-dentaire-article/iteration-2/2_kgp7by.png"
    alt="Lazy-loading Google Maps (Test 2)"
  />
  <figcaption>Lazy-loading Google Maps (Test 2)</figcaption>
</figure>

<figure>
  <img loading="lazy"
    src="https://res.cloudinary.com/https-tinloof-com/image/upload/v1594848716/blog/sbai-dentaire-article/iteration-2/3_m8k11u.png"
    alt="Lazy-loading Google Maps (Test 3)"
  />
  <figcaption>Lazy-loading Google Maps (Test 3)</figcaption>
</figure>

| Metric                 | Initial value | New value | Overall Progress |
|------------------------|---------------|-----------|------------------|
| Performance            | 54            | 97.3      | + 43.3 (+ 80%)    |
| First Contentful Paint | 2.1 s         | 2.1 s     | 0                |
| Time to Interactive    | 10.6 s        | 2.6 s     | - 8 s            |

Let's summarize what we achieved:

- We brought the page performance from 54 to 97.3 (an improvement of 80%).
- We reduced the time it takes for the page to be interactive by 8 s.

# Improving font loading speed
When using the url provided by Google fonts to load fonts, we're actually loading a CSS file that loads a big number of variations of the font we want to use.

We can improve that by manually loading only the latin variations of the the fonts used in the page:

```css
  @font-face {
    font-family: "Montserrat";
    font-style: normal;
    font-weight: 400;
    font-display: swap;
    src: local("Montserrat Regular"), local("Montserrat-Regular"),
      url(https://fonts.gstatic.com/s/montserrat/v14/JTUSjIg1_i6t8kCHKm459WlhyyTh89Y.woff2)
        format("woff2");
    unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
      U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212,
      U+2215, U+FEFF, U+FFFD;
  }

  @font-face {
    font-family: "Montserrat";
    font-style: normal;
    font-weight: 500;
    font-display: swap;
    src: local("Montserrat Medium"), local("Montserrat-Medium"),
      url(https://fonts.gstatic.com/s/montserrat/v14/JTURjIg1_i6t8kCHKm45_ZpC3gnD_vx3rCs.woff2)
        format("woff2");
    unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
      U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212,
      U+2215, U+FEFF, U+FFFD;
  }

  @font-face {
    font-family: "Montserrat";
    font-style: normal;
    font-weight: 700;
    font-display: swap;
    src: local("Montserrat Bold"), local("Montserrat-Bold"),
      url(https://fonts.gstatic.com/s/montserrat/v14/JTURjIg1_i6t8kCHKm45_dJE3gnD_vx3rCs.woff2)
        format("woff2");
    unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
      U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212,
      U+2215, U+FEFF, U+FFFD;
  }
```

We also emphasize to the browser that we want to fetch these fonts as early as possible. To do so, we use the [preload](https://developer.mozilla.org/en-US/docs/Web/HTML/Preloading_content) attribute in the links used to load the fonts:

```html
        <link
          rel="preload"
          as="font"
          href="https://fonts.gstatic.com/s/montserrat/v14/JTUSjIg1_i6t8kCHKm459WlhyyTh89Y.woff2"
          crossOrigin="true"
        />
        <link
          rel="preload"
          as="font"
          href="https://fonts.gstatic.com/s/montserrat/v14/JTURjIg1_i6t8kCHKm45_ZpC3gnD_vx3rCs.woff2"
          crossOrigin="true"
        />
        <link
          rel="preload"
          as="font"
          href="https://fonts.gstatic.com/s/montserrat/v14/JTURjIg1_i6t8kCHKm45_ZpC3gnD_vx3rCs.woff2"
          crossOrigin="true"
        />
```

We also use  `<link rel=preconnect>` to emphasize to the browser the priority of these fonts when loading resources:

```html
<link rel="preconnect" href="https://fonts.gstatic.com/s/montserrat/" />
```

## The final test
Running Lighthouse after this gives the following results:

<figure>
  <img loading="lazy"
    src="https://res.cloudinary.com/https-tinloof-com/image/upload/v1595164149/blog/sbai-dentaire-article/iteration-3/1_qcfu90.png"
    alt="Improved fonts loading (Test 1)"
  />
  <figcaption>Improved fonts loading (Test 1)</figcaption>
</figure>

<figure>
  <img loading="lazy"
    src="https://res.cloudinary.com/https-tinloof-com/image/upload/v1595164149/blog/sbai-dentaire-article/iteration-3/2_wx5qm6.png"
    alt="Improved fonts loading (Test 2)"
  />
  <figcaption>Improved fonts loading (Test 2)</figcaption>
</figure>

<figure>
  <img loading="lazy"
    src="https://res.cloudinary.com/https-tinloof-com/image/upload/v1595164149/blog/sbai-dentaire-article/iteration-3/3_oz1zu8.png"
    alt="Improved fonts loading (Test 3)"
  />
  <figcaption>Improved fonts loading (Test 3)</figcaption>
</figure>

| Metric                 | Initial value | New value | Overall Progress |
|------------------------|---------------|-----------|------------------|
| Performance            | 54            | 97.3      | + 43.3 (+ 80%)   |
| First Contentful Paint | 2.1 s         | 1.8 s     | - 0.3 s          |
| Time to Interactive    | 10.6 s        | 2.9 s     | - 7.7 s          |

- We brought the page performance from 54 to 97.3 (an improvement of 80%).
- We reduced the First Contentful Paint by 300 ms.
- We reduced the time it takes for the page to be interactive by 7.7 s.

## Looking back at the competition
Here's how we're performing now compared to the rest of the competition:

<figure>
  <img loading="lazy"
    src="https://res.cloudinary.com/https-tinloof-com/image/upload/v1595177373/blog/sbai-dentaire-article/Performance_Metrics_Outcome_bxlklt.png"
    alt="Comparing performance with competitors"
  />
  <figcaption>Comparing performance with competitors</figcaption>
</figure>

Compared to its fastest competitor (C), Sbaï Dentaire overall performance score is 32% higher and its Time to Interactive is almost 3 times faster.

> **Note:** we'd love to help you improve or build blazing fast web pages. [Get in touch with us](https://tinloof.com/contact).