<aside> ❗ This guide suggests that most of the basic optimization is already done, by following the following guides:

How to preload critical chunks?

How to split preload and rendering?

How to inline main chunk?

</aside>

Before optimizing CMS, it is required to analyze the page to determine its critical elements and what should be considered high-priority elements.

In most cases, the LCP element on CMS is considered to be one of the following:

CMS_deprioritize.png

To verify, it can be tested with the performance insights tool, to check which element will be considered as LCP on page.

CMS_LCP.png

Now when the LCP element is known, it is possible to determine a possible solution for the critical rendering path to this element. At the moment critical rendering path is with requests/code that slows down rendering on LCP.

At the moment, the critical rendering path looks like this:

CMS_waterfall.png

Looking at the requests waterfall, we can notice that the current behavior is for the content of the entire CMS page to get requested all at once. This leads to the page feeling slower to users since they are waiting for things that are out of view or not directly impacting their experience to load alongside what they actually care about.

Therefore, our main goal would be to load what matters the most first and everything else afterward. So the request waterfall will look something like this:

CMS_waterfall_optimized.png

Preloading LCP content

The first optimization step would be to include the critical CMS blocks in the server-side rendered HTML sent to the client. This means we can render the critical parts of the CMS page as soon as the CMS chunk is loaded without the need to wait for any GraphQL requests to be made.

To achieve this we need to make the following modifications to the code:

Under a script tag in the scandipwa/public/index.php file, window.actionName needs to have the following properties:

window.actionName = {
    type: `<?= $this->getAction(); ?>`,
    page: `<?= json_encode($this->getPage()); ?> || {}`,
    description: `<?= $this->getDescription(); ?>`,
    base_url: `<?= $this->getBaseUrl(); ?>`,
    media_url: `<?= $this->getMediaUrl(); ?>`,
		// ...
		// ... There will be props for other pages here
};

Also, under the same script tag we need to add the following logic to preload media:

// vvv Generate preload links
const chunkValidator = {
    //vvv Preload pages conditionally
    cms: window.actionName.type === 'CMS_PAGE',
		// ...
		// ... There will be props for other pages here
		// ...
    render: true,
    //vvv Always preload the current locale
    [window.defaultLocale]: true
};

const appendPreloadLink = (chunk) => {
    const link = document.createElement('link');
    link.rel = 'preload';
    link.as = 'script';
    link.href = chunk;
    document.head.appendChild(link);
}

if (window.preloadData) {
    Object.entries(window.preloadData).forEach(([key, chunks]) => {
        if (chunkValidator[key]) {
            chunks.forEach((c) => appendPreloadLink(c));
        }
    });
}

const preloadImage = (imageUrl) => {
    const link = document.createElement('link');
    link.rel = 'preload';
    link.as = 'image';
    link.href = imageUrl;

    document.head.appendChild(link);
};

if (window.actionName.type === 'CMS_PAGE') {
    if (window.actionName.imageUrlsPreload.length) {
        if(window.innerWidth > 810) {
            preloadImage(window.actionName.imageUrlsPreload?.[0]);
        } else {
            preloadImage(window.actionName.imageUrlsPreload?.[1]);
        }
    }
}

The changes we’ve made so far will only take effect on the backend, so if we only have ScandiPWA setup locally and we’re using a proxy to connect to a live backend we can do the following step:

Create the file scandipwa/public/index.html (if it doesn’t exist) and hard-code the values we expect to get from the backend:

<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, shrink-to-fit=no, viewport-fit=cover">

    <!-- Default Meta -->
    <title>ScandiPWA</title>
    <meta name="theme-color" content="#ffffff" />
    <meta name="description" content="Web site created using create-scandipwa-app" />

    <!-- Default content-configurations -->
    <script>
        (function() {
            if (typeof globalThis === 'object') return;
            Object.prototype.__defineGetter__('__magic__', function() {
                return this;
            });
            __magic__.globalThis = __magic__;
            delete Object.prototype.__magic__;
        }());

        window.defaultLocale = `en_US`;
        window.storeConfig =  {"homepageIdentifier":"home"} || {};

        window.contentConfiguration = {
            "contact_us_content": {
                "contact_us_cms_block": "contact_us_page_block"
            },
            "footer_content": {
                "footer_cms": null
            },
            "minicart_content": {
                "minicart_cms": null
            },
            "cart_content": {
                "cart_cms": null
            },
            "checkout_content": {
                "checkout_shipping_cms": null,
                "checkout_billing_cms": null
            },
            "header_content": {
                "header_menu": "rockler-main-menu",
                "header_mobile_menu": "rockler-mobile-menu",
                "contacts_cms": null
            },
            "product_list_content": {
                "attribute_to_display": null
            },
            "cookie_content": {
                "cookie_text": null,
                "cookie_link": null
            }
        };

        window.actionName = {
            type: "PWA_ROUTER",
            page: // Add the hard-coded CMS content here
				    description: // Add the hard-coded description here
				    base_url: // Add the hard-coded base_url here
				    media_url: // Add the hard-coded media_url here
						// ...
						// ... There will be props for other pages here
        }

        // vvv Generate preload links
        const chunkValidator = {
            //vvv Preload pages conditionally
            cms: window.actionName.type === 'CMS_PAGE',
						// ...
						// ... There will be props for other pages here
						// ...
            render: true,
            [window.defaultLocale]: true
        };

        const appendPreloadLink = (chunk) => {
            const link = document.createElement('link');
            link.rel = 'preload';
            link.as = 'script';
            link.href = chunk;
            document.head.appendChild(link);
        }

        if (window.preloadData) {
            Object.entries(window.preloadData).forEach(([key, chunks]) => {
                if (chunkValidator[key]) {
                    chunks.forEach((c) => appendPreloadLink(c));
                }
            });
        }

        const preloadImage = (imageUrl) => {
            const link = document.createElement('link');
            link.rel = 'preload';
            link.as = 'image';
            link.href = imageUrl;

            document.head.appendChild(link);
        };

        if (window.actionName.type === 'CMS_PAGE') {
            if (window.actionName.imageUrlsPreload.length) {
                if(window.innerWidth > 810) {
                    preloadImage(window.actionName.imageUrlsPreload?.[0]);
                } else {
                    preloadImage(window.actionName.imageUrlsPreload?.[1]);
                }
            }
        }

				// ... There will be code for other pages here

        // do reverse sort in order prevent an issue like store code `en` replaces store code `en_us`
        window.storeList = ['default'].sort().reverse();
        window.storeRegexText = `/(${window.storeList.join('|')})?`;
        window.metaHtml = `
        <!-- Manifest -->
        <link rel="manifest" href="/manifest.json">
        `;
    </script>

    <!-- Icons -->
    <link rel="shortcut icon" href="/icon_ios_640x640.png"/>

    <link rel="apple-touch-startup-image" href="/icon_ios_640x640.png">
    <link rel="apple-touch-icon" href="/icon_ios_640x640.png">
    <link rel="icon" href="/icon_ios_640x640.png">
</head>
<body id="html-body">
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
</body>
</html>

Deprioritizing non-critical path

Now that we’ve preloaded the main CMS block, our next step is to make everything else wait till the part that is considered LCP finishes loading and then start requesting and rendering everything else.

We can achieve this in 2 steps

  1. Waiting for window.isPriorityLoaded to be set to true:

    There are multiple places we need to investigate for potential deprioritization, such as:

    <aside> 📢 Make sure after you change imports to use the lowPriorityLazy that you go through the place where the component is used and wrap it with a Suspense

    </aside>

    import { createElement, Suspense } from 'react';
    
    import { lowPriorityLazy } from 'Util/Request/PriorityLoad';
    
    import { ACCORDION_CONTENT_TYPE, ACCORDION_SKELETON } from '../../component/Accordion/Accordion.config';
    
    export const Accordion = lowPriorityLazy(() => import(
        /* webpackMode: "lazy", webpackChunkName: "cms-misc" */ '../../component/Accordion'
    ));
    
    const addReplacementRule = (originalMember, instance) => ([
        ...originalMember,
        {
            query: { dataContentType: ACCORDION_CONTENT_TYPE },
            replace: (domNode) => (
    						// vvv Wrapping the Accordion component with Suspense
                <Suspense fallback={ <div /> }>
                    { createElement(Accordion, {
                        elements: instance.toReactElements(
                            [domNode],
                            ACCORDION_SKELETON
                        )
                    }) }
                </Suspense>
            )
        }
    ]);
    
    export default {
        'Component/Html/Component': {
            'member-property': {
                rules: addReplacementRule
            }
        }
    };
    
    
  2. Set window.isPriorityLoaded to true once the main content is loaded:

    Now that we have non-critical components awaiting for the LCP to load we need to implement the logic for tracking when the LCP is loaded and inform all the low priority components to start loading.

    But we might face an issue here and that is the main LCP might be a div tag with a background image instead of an img tag. This is problematic because we can’t listen to a load event on a CSS property so we need to switch that into an image instead.

    To achieve the above we need to create the following two components:

    We’ll be using the BackgroundImage component in scandipwa/src/component/Html/Html.component.js for rendering our preloaded LCP, and we have to pass the onLoad prop a function that sets window.isPriorityLoaded = true.

    import BackgroundImage from '../BackgroundImage';
    
    // ... Some code here
    
    /** @namespace Scandipwa/Component/Html/Component */
    export class HtmlComponent extends RocklerHtmlComponent {
    		isFirstBackgroundImage = true;
    
        isHighPriorLoading = false;
    
        isRenderingRowWithImage = false;
    
    		// ... More methods here
    
    		replaceBackgroundImage(domNode) {
            const { attribs, children, name } = domNode;
    
            if (!this.isFirstBackgroundImage) {
                return (
                    <AfterPriority fallback={ null }>
                        <div { ...this.attributesToProps(attribs) }>
                            { domToReact(children, this.parserOptions) }
                        </div>
                    </AfterPriority>
                );
            }
    
            if (this.isFirstBackgroundImage) {
                // for first block, always show
                this.isFirstBackgroundImage = false;
            }
    
            const images = JSON.parse(attribs['data-background-images'].replace(/\\\\(.)/mg, '$1')) || {};
    
            this.isHighPriorLoading = true;
    
            const render = (
              <BackgroundImage
                props={ this.attributesToProps(attribs) }
                name={ name }
                images={ images }
                isHighPriorImage={ !this.isHighPriorLoading }
                onLoad={ () => {
                    window.isPriorityLoaded = true;
                    this.setPriorityLoaded();
                } }
              >
                { domToReact(children, this.parserOptions) }
              </BackgroundImage>
            );
    
            return render;
        }
    }
    

Minimizing chunk size

Once we’ve take care of all the priority loading logic we need to pay attention to chunk sizes. We achieve that by changing the webpackChunkName for all the lazy and lowPriorityLazy imports to not be using any of the main chunks that will be loaded at first. So we can give them the following name "cms-misc". Here are examples from a couple of different files:

export const Accordion = lowPriorityLazy(() => import(
    /* webpackMode: "lazy", webpackChunkName: "cms-misc" */
		'../../component/Accordion'
));

export const Buttons = lowPriorityLazy(() => import(
    /* webpackMode: "lazy", webpackChunkName: "cms-misc" */
    '../../component/Buttons'
));

export const GoogleMap = lowPriorityLazy(() => import(
	  /* webpackMode: "lazy", webpackChunkName: "cms-misc" */
	  '../../component/Map'
));

const Slider = lowPriorityLazy(() => import(
    /* webpackMode: "lazy", webpackChunkName: "cms-misc" */
    '../../component/Slider'
));

const Tab = lowPriorityLazy(() => import(
    /* webpackMode: "lazy", webpackChunkName: "cms-misc" */
    '../../component/Tab'
));

export const WidgetFactory = lowPriorityLazy(() => import(
    /* webpackMode: "lazy", webpackChunkName: "cms-misc" */
    'Component/WidgetFactory'
));

Another area of saving on size would be to move logic on the frontend that occupies a large space and can be executed on the server to the backend. One example of that would be the he library (with as size of 30KB gzipped) used by scandipwa/packages/@scandiweb/adobe-page-builder/src/plugin/html/HtmlComponent.plugin.js for decoding HTML strings. So we should remove any use of the he library and implement this logic on the backend instead: app/code/Scandiweb/CmsGraphQl/Model/Resolver/DataProvider/Page.php

<?php

declare(strict_types=1);

namespace Scandiweb\\CmsGraphQl\\Model\\Resolver\\DataProvider;

use ScandiPWA\\CmsGraphQl\\Model\\Resolver\\DataProvider\\Page as CorePage;
use ScandiPWA\\CmsGraphQl\\Api\\Data\\PageInterface;
use Magento\\Cms\\Api\\Data\\PageInterface as OriginalPageInterface;
use Magento\\Cms\\Api\\GetPageByIdentifierInterface;
use Magento\\Cms\\Api\\PageRepositoryInterface;
use Magento\\Framework\\Exception\\NoSuchEntityException;
use Magento\\Widget\\Model\\Template\\FilterEmulate;

/**
 * Cms page data provider
 */
class Page extends CorePage
{
    /**
     * @var FilterEmulate
     */
    protected $widgetFilter;

    /**
     * @var GetPageByIdentifierInterface
     */
    protected $pageByIdentifier;

    /**
     * @var PageRepositoryInterface
     */
    protected $pageRepository;

    /**
     * @param PageRepositoryInterface $pageRepository
     * @param FilterEmulate $widgetFilter
     * @param GetPageByIdentifierInterface $getPageByIdentifier
     */
    public function __construct(
        PageRepositoryInterface $pageRepository,
        FilterEmulate $widgetFilter,
        GetPageByIdentifierInterface $getPageByIdentifier
    ) {
        $this->pageRepository = $pageRepository;
        $this->widgetFilter = $widgetFilter;
        $this->pageByIdentifier = $getPageByIdentifier;

        parent::__construct(
            $pageRepository,
            $widgetFilter,
            $getPageByIdentifier
        );
    }

    /**
     * Returns page data by page_id
     *
     * @param int $pageId
     * @return array
     * @throws NoSuchEntityException
     */
    public function getDataByPageId(int $pageId): array
    {
        $page = $this->pageRepository->getById($pageId);

        return $this->convertPageData($page);
    }

    /**
     * Returns page data by page identifier
     *
     * @param string $pageIdentifier
     * @param int $storeId
     * @return array
     * @throws NoSuchEntityException
     */
    public function getDataByPageIdentifier(string $pageIdentifier, int $storeId): array
    {
        $page = $this->pageByIdentifier->execute($pageIdentifier, $storeId);

        return $this->convertPageData($page);
    }

    /**
     * Convert page data
     *
     * @param OriginalPageInterface $page
     * @return array
     * @throws NoSuchEntityException
     */
    public function convertPageData(OriginalPageInterface $page)
    {
        if (false === $page->isActive()) {
            throw new NoSuchEntityException();
        }

        $renderedContent = stripcslashes(
            htmlspecialchars_decode(
                $this->widgetFilter->filter($page->getContent())
            )
        );
        $sanitizedContent = preg_replace('/\\\\"}\\\\"/m', '"}\\'', preg_replace('/data-background-images=\\\\"{[^}]/m', 'data-background-images=\\'{\\\\"', $renderedContent));

        $pageData = [
            PageInterface::URL_KEY => $page->getIdentifier(),
            PageInterface::TITLE => $page->getTitle(),
            PageInterface::CONTENT => $sanitizedContent,
            PageInterface::CONTENT_HEADING => $page->getContentHeading(),
            PageInterface::PAGE_LAYOUT => $page->getPageLayout(),
            PageInterface::PAGE_WIDTH => $page->getPageWidth() ?: 'default',
            PageInterface::META_TITLE => $page->getMetaTitle(),
            PageInterface::META_DESCRIPTION => $page->getMetaDescription(),
            PageInterface::META_KEYWORDS => $page->getMetaKeywords(),
            PageInterface::PAGE_ID => $page->getId(),
            PageInterface::IDENTIFIER => $page->getIdentifier(),
        ];

        return $pageData;
    }
}

Scandiweb slider widget as LCP element