Sbaï Dentaire is the number 1 user-rated dental practice in Casablanca (Morocco) by Google users:
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.
Measuring performance steers our work by identifying potential performance issues and making sure we're progressing in the right direction after making any changes.
To measure the performance, we use Google's Lighthouse tool, which is available out of the box in Chrome:
To keep the test conditions as close to the live version, we make sure of the following:
We also keep track of 3 metrics:
When we run the first 3 tests with Lighthouse, we get the following:
When we average the metrics in the 3 tests, we get the following:
Although the First Contentful Paint time is acceptable, the performance score and the Time to Interactive should definitely be improved.
We ran the same tests with the other top 4 ranked dental practice websites and gathered the following data:
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):
Another issue is spotted in the Network Tab where we see that images are fetched simultaneously at page load:
We can do so by:
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:
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 :
We can then use the data to tell the browser to:
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:
function Image({ image, desktopImage, styles, alt }) {return (<picture>{desktopImage && (<><source media="(min-width: 480px)" srcSet={desktopImage.srcSet} /><sourcemedia="(min-width: 480px)"srcSet={desktopImage.srcSetWebp}type="image/webp"/></>)}<source srcSet={image.srcWebp} type="image/webp" /><Imgsrc={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:
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}><Imageimage={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:
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 :
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:
function Reviews() {const imagesMap = getImagesMap();return (...{data.map(review => (<Imagealt={review.name}image={imagesMap[review.image]}styles={styles.Img}/>}...);
All images are fetched in webp format and their sizes got dramatically reduced. The Network Tab shows the following:
We also see that images are lazy-loaded on scroll whenever they're close to appearing in the viewport:
Now that all images issues are fixed, let's run Lighthouse again and check the website's performance:
We solved the issues with images, which resulted in a noticeable performance improvement:
When we look at Lighthouse reported problems, we find an issue with the Google Maps used in the Contact section:
We see 2 problems:
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:
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:
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>);}
We do so by checking the Network Tab while scrolling:
Lighthouse gives the following new performance metrics:
Let's summarize what we achieved:
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:
@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 attribute in the links used to load the fonts:
<linkrel="preload"as="font"href="https://fonts.gstatic.com/s/montserrat/v14/JTUSjIg1_i6t8kCHKm459WlhyyTh89Y.woff2"crossorigin="true"/><linkrel="preload"as="font"href="https://fonts.gstatic.com/s/montserrat/v14/JTURjIg1_i6t8kCHKm45_ZpC3gnD_vx3rCs.woff2"crossorigin="true"/><linkrel="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:
<link rel="preconnect" href="https://fonts.gstatic.com/s/montserrat/" />
Running Lighthouse after this gives the following results:
Here's how we're performing now compared to the rest of the competition:
Compared to its fastest competitor (C), Sbaï Dentaire overall performance score is 32% higher and its Time to Interactive is almost 3 times faster.