DEV Community

Cover image for Docusaurus: improving Core Web Vitals with fetchpriority
John Reilly
John Reilly

Posted on • Updated on • Originally published at johnnyreilly.com

Docusaurus: improving Core Web Vitals with fetchpriority

By using fetchpriority on your Largest Contentful Paint you can improve your Core Web Vitals. This post implements that with Docusaurus.

title image reading "Docusaurus: improving Core Web Vitals with fetchpriority"

Avoiding lazy loading on the Largest Contentful Paint

At the weekend I wrote a post documenting how I believe I ruined the SEO on my blog. That post ended up trending on Hacker News. People made suggestions around things I could do that could improve things. One post in particular caught my eye from Growtika saying:

Page speed: It's one of the most important ranking factor. You don't have to get 100 score, but passing the core web vitals score and having higher score on mobile is recommended.

https://pagespeed.web.dev/report?url=https%3A%2F%2Fjohnnyreilly.com%2F&form_factor=mobile

A cool trick to improve the result fast is by removing the lazy load effect from the LCP:

screenshot of web test results that reads largest contentful paint image was lazily loaded

Another person chimed in with:

Indeed. Even better, making it high priority instead of normal: https://addyosmani.com/blog/fetch-priority/

fetchpriority

I hadn't heard of fetchpriority before this, but the linked article by Addy Osmani carried this tip:

Add fetchpriority="high" to your Largest Contentful Paint (LCP) image to get it to load sooner. Priority Hints sped up Etsy’s LCP by 4% with some sites seeing an improvement of up to 20-30% in their lab tests. In many cases, fetchpriority should lead to a nice boost for LCP.

I was keen to try this out. Somewhat interestingly, I was the person responsible for originally contributing lazy loading to Docusaurus. For what it's worth, lazy loading is a good thing to do. It's just that in this case, it was causing the LCP to be lazy loaded. I wanted to change that.

Swizzling the image component

Since my initial contribution, the implementation had been tweaked to allow user control via Swizzling. By the way, swizzling is a great feature of Docusaurus. It allows you to override the default implementation of a component. In this case, I wanted to override the Img component and opt out of lazy loading. I did this by running the following command:

yarn swizzle @docusaurus/theme-classic MDXComponents/Img -- --eject
Enter fullscreen mode Exit fullscreen mode

This created a file at src/theme/MDXComponents/Img.js. I then made the following change:

import React from 'react';
import clsx from 'clsx';
import styles from './styles.module.css';
function transformImgClassName(className) {
  return clsx(className, styles.img);
}
export default function MDXImg(props) {
  return (
    // eslint-disable-next-line jsx-a11y/alt-text
    <img
-      loading="lazy"
      {...props}
      className={transformImgClassName(props.className)}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Getting rid of the loading="lazy" attribute was all I needed to do. This gets us to the point where none of our images are lazy loaded anymore. Stage 1 complete!

Adding fetchpriority="high" to the LCP with a custom plugin

The next thing to do was to write a small Rehype plugin to add fetchpriority="high" to the LCP. I did this by creating a new JavaScript file called image-fetchpriority-rehype-plugin.js:

// @ts-check
const visit = require('unist-util-visit');

/**
 * Create a rehype plugin that will make the first image eager loaded with fetchpriority="high" and lazy load all other images
 * @returns rehype plugin that will make the first image eager loaded with fetchpriority="high" and lazy load all other images
 */
function imageFetchPriorityRehypePluginFactory() {
  /** @type {Map<string, string>} */ const files = new Map();

  /** @type {import('unified').Transformer} */
  return (tree, vfile) => {
    visit(tree, ['element', 'jsx'], (node) => {
      if (node.type === 'element' && node['tagName'] === 'img') {
        // handles nodes like this:
        // {
        //   type: 'element',
        //   tagName: 'img',
        //   properties: {
        //     src: 'https://some.website.com/cat.gif',
        //     alt: null
        //   },
        //   ...
        // }

        const key = `img|${vfile.history[0]}`;
        const imageAlreadyProcessed = files.get(key);
        const fetchpriorityThisImage =
          !imageAlreadyProcessed ||
          imageAlreadyProcessed === node['properties']['src'];

        if (!imageAlreadyProcessed) {
          files.set(key, node['properties']['src']);
        }

        if (fetchpriorityThisImage) {
          node['properties'].fetchpriority = 'high';
          node['properties'].loading = 'eager';
        } else {
          node['properties'].loading = 'lazy';
        }
      } else if (node.type === 'jsx' && node['value']?.includes('<img ')) {
        // handles nodes like this:

        // {
        //   type: 'jsx',
        //   value: '<img src={require("!/workspaces/blog.johnnyreilly.com/blog-website/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[hash].[ext]&fallback=/workspaces/blog.johnnyreilly.com/blog-website/node_modules/file-loader/dist/cjs.js!./bower-with-the-long-paths.png").default} width="640" height="497" />'
        // }

        // if (!vfile.history[0].includes('blog/2023-01-15')) return;

        const key = `jsx|${vfile.history[0]}`;
        const imageAlreadyProcessed = files.get(key);
        const fetchpriorityThisImage =
          !imageAlreadyProcessed || imageAlreadyProcessed === node['value'];

        if (!imageAlreadyProcessed) {
          files.set(key, node['value']);
        }

        if (fetchpriorityThisImage) {
          node['value'] = node['value'].replace(
            /<img /g,
            '<img loading="eager" fetchpriority="high" '
          );
        } else {
          node['value'] = node['value'].replace(
            /<img /g,
            '<img loading="lazy" '
          );
        }
      }
    });
  };
}

module.exports = imageFetchPriorityRehypePluginFactory;
Enter fullscreen mode Exit fullscreen mode

The above plugin runs over the AST of the MDX file and adds fetchpriority="high" to the first image. It also adds loading="eager" to the first image and loading="lazy" to all other images.

Interestingly, when I was writing it I discovered that the visitor is invoked multiple times for the same elements. I'm not quite sure why, but the logic in the plugin uses a Map to keep track of which images have already been processed. TL;DR it works!

I then added the plugin to the docusaurus.config.js file:

//@ts-check
const imageFetchPriorityRehypePlugin = require('./image-fetchpriority-rehype-plugin');

/** @type {import('@docusaurus/types').Config} */
const config = {
  // ...
  presets: [
    [
      '@docusaurus/preset-classic',
      /** @type {import('@docusaurus/preset-classic').Options} */
      ({
        // ...
        blog: {
          // ...
          rehypePlugins: [imageFetchPriorityRehypePlugin],
          // ...
        },
        // ...
      }),
    ],
  ],
  // ...
};

module.exports = config;
Enter fullscreen mode Exit fullscreen mode

What does it look like when applied?

Now we have this in place, if we run the same test with pagespeed we have different results:

screenshot showing fetchpriority="high" has been applied to LCP image

We're now not lazy loading the image and we're also making it a high priority fetch. Great news!

I'd like for this to be the default behaviour for Docusaurus. I'm not sure if it's possible to do this in a way that's straightforward. I've raised an issue on the Docusaurus repo to see if it's possible.

Latest comments (0)