19th July 2020 • 11 min read
How we boosted the performance of a Gatsby website by 80%
Seif Ghezala
Sbaï Dentaire is the number 1 user-rated dental practice in Casablanca (Morocco) by Google users:
Google Reviews results for Sbaï Dentaire
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.
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.
However, there is a plan to integrate a blog to the website in the future and Gatsby makes it easier.
Final perf
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:
Google Lighthouse tool
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:
Lighthouse recommendation about Chrome extensions
- The Simulated Throttling option is enabled in Lighthouse. This enables us to simulate a realistic slow network connection:
Network throttling option
- 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:
Current situation (Test 1)
Current situation (Test 2)
Current situation (Test 3)
When we average the metrics in the 3 tests, we get the following:
- Metric: Performance
- Value: 54
- Metric: First Contentful Paint
- Value: 2.1 s
- Metric: Time to Interactive
- Value: 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:
Comparing performance with competitors
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.
One problem we quickly notice is that the website is making huge network requests to fetch images (mostly greater than 600 KB of payload):
Enormous image requests
Another issue is spotted in the Network Tab where we see that images are fetched simultaneously at page load:
Images fetched simultaneously
We can do so by:
- Using 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.
- Lazy-loading images 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.
- Saving on request payload by reducing the requested size on mobile. This technique is known as Art Direction.
We'd normally use gatsby-image to handle the previous issues, but there is a bug in the library affecting Art Direction. Fortunately, we can use the module gatsby-plugin-sharp with the native <picture> tag to achieve the same result.
gatsby-plugin-sharp can apply the necessary transformations through GraphQL queries. Here's an example query we used to transform home.png, the image used in the home section:
1function getImageSources() {
2 const data = useStaticQuery(graphql`
3 query {
4 mobileImage: file(relativePath: { eq: "home.png" }) {
5 childImageSharp {
6 fixed(width: 500) {
7 ...GatsbyImageSharpFixed_withWebp_noBase64
8 }
9 }
10 }
11 desktopImage: file(relativePath: { eq: "home.png" }) {
12 childImageSharp {
13 fixed(width: 900) {
14 ...GatsbyImageSharpFixed_withWebp_noBase64
15 }
16 }
17 }
18 }
19 `);
20
21 return {
22 mobileImage: data.mobileImage.childImageSharp.fixed,
23 desktopImage: data.desktopImage.childImageSharp.fixed,
24 };
25}
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:
1function Image({ image, desktopImage, styles, alt }) {
2 return (
3 <picture>
4 {desktopImage && (
5 <>
6 <source media="(min-width: 480px)" srcSet={desktopImage.srcSet} />
7 <source
8 media="(min-width: 480px)"
9 srcSet={desktopImage.srcSetWebp}
10 type="image/webp"
11 />
12 </>
13 )}
14 <source srcSet={image.srcWebp} type="image/webp" />
15 <Img
16 src={image.src}
17 srcSet={image.srcSet}
18 alt="Homepage"
19 loading="lazy"
20 css={styles}
21 alt={alt}
22 />
23 </picture>
24 );
25}
26
27const imageShape = PropTypes.shape({
28 src: PropTypes.string.isRequired,
29 srcSet: PropTypes.string,
30 srcWebp: PropTypes.string,
31 srcSetWebp: PropTypes.string,
32});
33
34Image.propTypes = {
35 image: imageShape.isRequired,
36 desktopImage: imageShape,
37};
Here's how the home section component uses it:
1function Home() {
2 const { mobileImage, desktopImage } = getImageSources();
3
4 return (
5 <div id="home" css={styles.home}>
6 <section css={styles.textContainer}>
7 <section>
8 <h1>Un beau sourire à Casablanca</h1>
9 <p>Assuré par un soin dentaire de qualité depuis 30 ans</p>
10 </section>
11 <a className="button primary" href="#contact">
12 Nous contacter
13 </a>
14 </section>
15 <div css={styles.imageContainer}>
16 <Image
17 image={mobileImage}
18 desktopImage={desktopImage}
19 alt="Homepage"
20 styles={styles.Img}
21 />
22 </div>
23 </div>
24 );
25}
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:
1function getImagesMap() {
2 const data = useStaticQuery(graphql`
3 query {
4 allFile(filter: { : { eq: "reviews" } }) {
5 nodes {
6 childImageSharp {
7 fixed(width: 90) {
8 ...GatsbyImageSharpFixed_withWebp_noBase64
9 }
10 }
11 name
12 }
13 }
14 }
15 `);
16
17 return imagesToMap(data.allFile.nodes);
18}
19
And here's the body of imagesToMap :
1function imagesToMap(images) {
2 return images.reduce(
3 (acc, { name, childImageSharp: { fixed } }) => ({ ...acc, [name]: fixed }),
4 {}
5 );
6}
We then iterate through the images data and render them with our Image component:
1function Reviews() {
2 const imagesMap = getImagesMap();
3
4 return (
5 ...
6 {data.map(review => (
7 <Image
8 alt={review.name}
9 image={imagesMap[review.image]}
10 styles={styles.Img}
11 />
12 }
13 ...
14);
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:
Optimized images
We also see that images are lazy-loaded on scroll whenever they're close to appearing in the viewport:
Resulting performance
Now that all images issues are fixed, let's run Lighthouse again and check the website's performance:
Optimizing images (Test 1)
Optimizing images (Test 2)
Optimizing images (Test 3)
- Metric: Performance
- Initial Value: 54
- New Value: 63.3
- Overall Progress: + 9.3 (+ 17%)
- Metric: First Contentful Paint
- Initial Value: 2.1 s
- New Value: 1.8 s
- Overall Progress: - 0.3 s
- Metric: Time to Interactive
- Initial Value: 10.6 s
- New Value: 9.2 s
- Overall Progress: - 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.
When we look at Lighthouse reported problems, we find an issue with the Google Maps used in the Contact section:
Google Maps issue
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:
1const THRESHOLD = 0;
2
3export default function useInViewPort() {
4 let nodeRef = React.useRef(null);
5 let observerRef = React.useRef(null);
6
7 const [isVisible, setIsVisible] = React.useState(false);
8
9 React.useEffect(() => {
10 observerRef.current = new IntersectionObserver(
11 (entries) => {
12 setIsVisible(entries[0].isIntersecting);
13 },
14 { THRESHOLD }
15 );
16
17 observerRef.current.observe(nodeRef.current);
18
19 return () => {
20 observerRef.current.disconnect();
21 };
22 }, []);
23
24 React.useEffect(() => {
25 if (isVisible) {
26 observerRef.current.disconnect();
27 }
28 }, [isVisible]);
29
30 return [nodeRef, isVisible];
31}
We then use it in the Contact section to lazy-load Google Maps:
1function Contact() {
2 const mapRef = React.useRef();
3 const [nodeRef, isVisible] = useInViewport();
4
5 function initialize() {
6 new window.google.maps.Map(mapRef.current, mapOptions);
7 }
8
9 React.useEffect(() => {
10 if (isVisible) {
11 const script = document.createElement("script");
12 script.src = `https://maps.googleapis.com/maps/api/js?key=${API_KEY}&language=fr`;
13 script.addEventListener("load", initialize);
14 document.body.appendChild(script);
15 }
16 }, [isVisible]);
17
18 return (
19 <div ref={nodeRef}>
20 ...
21 <section>
22 <div ref={mapRef} css={styles.map}></div>
23 </section>
24 ...
25 </div>
26 );
27}
Checking if Google Maps is lazy-loaded
We do so by checking the Network Tab while scrolling:
Measuring the new performance
Lighthouse gives the following new performance metrics:
Lazy-loading Google Maps (Test 1)
Lazy-loading Google Maps (Test 2)
Lazy-loading Google Maps (Test 3)
- Metric: Performance
- Initial Value: 54
- New Value: 97.3
- Overall Progress: + 43.3 (+ 80%)
- Metric: First Contentful Paint
- Initial Value: 2.1 s
- New Value: 2.1 s
- Overall Progress: 0
- Metric: Time to Interactive
- Initial Value: 10.6 s
- New Value: 2.6 s
- Overall Progress: - 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.
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:
1@font-face {
2 font-family: "Montserrat";
3 font-style: normal;
4 font-weight: 400;
5 font-display: swap;
6 src: local("Montserrat Regular"), local("Montserrat-Regular"),
7 url(https://fonts.gstatic.com/s/montserrat/v14/JTUSjIg1_i6t8kCHKm459WlhyyTh89Y.woff2)
8 format("woff2");
9 unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
10 U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
11 U+FEFF, U+FFFD;
12}
13
14@font-face {
15 font-family: "Montserrat";
16 font-style: normal;
17 font-weight: 500;
18 font-display: swap;
19 src: local("Montserrat Medium"), local("Montserrat-Medium"),
20 url(https://fonts.gstatic.com/s/montserrat/v14/JTURjIg1_i6t8kCHKm45_ZpC3gnD_vx3rCs.woff2)
21 format("woff2");
22 unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
23 U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
24 U+FEFF, U+FFFD;
25}
26
27@font-face {
28 font-family: "Montserrat";
29 font-style: normal;
30 font-weight: 700;
31 font-display: swap;
32 src: local("Montserrat Bold"), local("Montserrat-Bold"),
33 url(https://fonts.gstatic.com/s/montserrat/v14/JTURjIg1_i6t8kCHKm45_dJE3gnD_vx3rCs.woff2)
34 format("woff2");
35 unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
36 U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
37 U+FEFF, U+FFFD;
38}
39
We also emphasize to the browser that we want to fetch these fonts as early as possible. To do so, we use the preload attribute in the links used to load the fonts:
1<link
2 rel="preload"
3 as="font"
4 href="https://fonts.gstatic.com/s/montserrat/v14/JTUSjIg1_i6t8kCHKm459WlhyyTh89Y.woff2"
5 crossorigin="true"
6/>
7<link
8 rel="preload"
9 as="font"
10 href="https://fonts.gstatic.com/s/montserrat/v14/JTURjIg1_i6t8kCHKm45_ZpC3gnD_vx3rCs.woff2"
11 crossorigin="true"
12/>
13<link
14 rel="preload"
15 as="font"
16 href="https://fonts.gstatic.com/s/montserrat/v14/JTURjIg1_i6t8kCHKm45_ZpC3gnD_vx3rCs.woff2"
17 crossorigin="true"
18/>
19
We also use <link rel=preconnect> to emphasize to the browser the priority of these fonts when loading resources:
1<link rel="preconnect" href="https://fonts.gstatic.com/s/montserrat/" />
2
The final test
Running Lighthouse after this gives the following results:
Improved fonts loading (Test 1)
Improved fonts loading (Test 2)
Improved fonts loading (Test 3)
- Metric: Performance
- Initial Value: 54
- New Value: 97.3
- Overall Progress: + 43.3 (+ 80%)
- Metric: First Contentful Paint
- Initial Value: 2.1 s
- New Value: 1.8 s
- Overall Progress: - 0.3 s
- Metric: Time to Interactive
- Initial Value: 10.6 s
- New Value: 2.9 s
- Overall Progress: - 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:
Comparing performance with competitors
Compared to its fastest competitor (C), Sbaï Dentaire overall performance score is 32% higher and its Time to Interactive is almost 3 times faster.