DEV Community

Cover image for Frontend Performance Optimization tips | Magento 2 | Adobe Commerce
Denys Sliepnov for run_as_root GmbH

Posted on

Frontend Performance Optimization tips | Magento 2 | Adobe Commerce

Some backstory

Everybody knows that the performance is important. Many of us have already done many things to improve our performance.
Performance optimization is a complex process that requires a lot of time and effort.
It's a never-ending process, and you should always keep in mind that you can do better.
I'm also kept the performance optimization challenge and spent a lot of time improving the performance of the Magento 2 store.
You know what is a headache to speed up a big project.

The beginning of the journey

I started with the Google Lighthouse tool.
It's a great tool that helps you to find the most common problems with the performance.
I read a lot of articles about performance optimization and tried to apply the best practices.
I would suggest this article, combining most practices.
I don't want to repeat the same things, you are already familiar with them, I guess.
Most of them are well-known and described in the official documentation.
They are really helpful, but I still had many problems with the performance.

The next step - the deep dive into the performance by myself

After I applied all the best practices, I still had a lot of problems with the performance:

  • The DOM size was too big
  • The JS main-thread work was too long
  • The Largest Contentful Paint (LCP) score was too high

I can't remove required blocks from the page, I can't remove required JS code, I can't remove required images.
I decided to go deeper and started to investigate the performance by myself.
There are no resources how to decrease a DOM size, how to decrease a JS main-thread work, how to decrease a LCP score. Or I didn't find them, ha-ha.
So, let me introduce the insights that I found during my investigation.

Decrease the DOM size whenever it's possible

Don't create unnecessary DOM elements

Each DOM element has a cost. The more elements you have, the more memory is used.
The more memory is used, the slower the page is. So, don't create unnecessary DOM elements.

Try to avoid rendering elements that are not visible

  • If you have some visible component in the viewport that has some part of visible elements, like a button or input, and a tree of elements that are not visible, try to avoid rendering them. It makes sense to use some Visual Fasade. It's a component that renders only visible elements and hides the rest, rendering of the hidden elements will be started after the user interaction.

Example:

Your store uses the Search Autocomplete component. It renders a list of suggestions. The list is hidden by default.
When the user starts typing, the list is shown. The list is rendered only when the user starts typing.
It has a visible input element and hidden templates for suggestions.
Visible elements are rendered immediately, and the rest of the list is rendered after the user interaction.

  • If you have elements that are not visible, but they are rendered, try to prevent this. It's possible to render the specific part of the content after the user interaction. To do this, you can use the following steps:
    • assign a hidden content to the JS variable:
// We use the $escaper variable to make sure that the JS content inside the child block is escaped properly.
<script>
    const hiddenContent = `<?= $escaper->escapeJs($block->getChildHtml('block_identifier')) ?>`;
</script>
Enter fullscreen mode Exit fullscreen mode
  • use wrapper element to render the content:
<div id="wrapper"></div>
Enter fullscreen mode Exit fullscreen mode
  • inject a few helper js functions:
// The 'init-external-scripts event' is triggered when any of the standard 
// interaction events (touchstart, mouseover, wheel, scroll, keydown) are fired, 
// and it also handles the cleanup, so the event is only fired once.
(events => {
    const dispatchUserInteractionEvent = () => {
        events.forEach(type => window.removeEventListener(type, dispatchUserInteractionEvent))
        window.dispatchEvent(new Event('init-external-scripts'))
    };
    events.forEach(type => window.addEventListener(type, dispatchUserInteractionEvent, {once: true, passive: true}))
})(['touchstart', 'mouseover', 'wheel', 'scroll', 'keydown'])

// This function renders the content inside the wrapper element. 
// And it also handles the script execution when the content is rendered inside the wrapper element.
function setInnerHTML(elm, html) {
    elm.innerHTML = html;

    Array.from(elm.getElementsByTagName("script"))
        .forEach(oldScriptEl => {
            const newScriptEl = document.createElement("script");

                Array.from(oldScriptEl.attributes).forEach(attr => {
                newScriptEl.setAttribute(attr.name, attr.value)
            });

            const scriptText = document.createTextNode(oldScriptEl.innerHTML);
            newScriptEl.appendChild(scriptText);

            oldScriptEl.parentNode.replaceChild(newScriptEl, oldScriptEl);
        });
    }
Enter fullscreen mode Exit fullscreen mode
  • render the content after the user interaction:
window.addEventListener('init-external-scripts', () => {
    const wrapper = document.getElementById('wrapper');
    setInnerHTML(wrapper, hiddenContent);
});
Enter fullscreen mode Exit fullscreen mode

Full example:


<div id="wrapper"></div>

<script>
    (events => {
        const dispatchUserInteractionEvent = () => {
            events.forEach(type => window.removeEventListener(type, dispatchUserInteractionEvent))
            window.dispatchEvent(new Event('init-external-scripts'))
        };
        events.forEach(type => window.addEventListener(type, dispatchUserInteractionEvent, {once: true, passive: true}))
    })(['touchstart', 'mouseover', 'wheel', 'scroll', 'keydown'])

    function setInnerHTML(elm, html) {
        elm.innerHTML = html;

        Array.from(elm.getElementsByTagName("script"))
            .forEach(oldScriptEl => {
                const newScriptEl = document.createElement("script");

                Array.from(oldScriptEl.attributes).forEach(attr => {
                    newScriptEl.setAttribute(attr.name, attr.value)
                });

                const scriptText = document.createTextNode(oldScriptEl.innerHTML);
                newScriptEl.appendChild(scriptText);

                oldScriptEl.parentNode.replaceChild(newScriptEl, oldScriptEl);
            });
    }

    const hiddenContent = `<?= $escaper->escapeJs($block->getChildHtml('block_identifier')) ?>`;

    window.addEventListener('init-external-scripts', () => {
        const wrapper = document.getElementById('wrapper');
        setInnerHTML(wrapper, hiddenContent);
    });
</script>
Enter fullscreen mode Exit fullscreen mode

This approach minimizes the DOM size and JS main-thread work (script execution).
Also, if hidden content contains some images, the loading of the images will be started after the user interaction.

Defer non-critical JS initialization and execution

If you have some custom JS code that is not required for the initial page load, try to defer its execution.
Imagine that you have some custom JS code that pushes page data to external system.

Example:

function pushPageDataToExternalSystem() {
    // Get page data
    const pageData = {
        url: window.location.href,
        title: document.title,
        // ...
    };

    // Push page data to external system
    externalSystem.push(pageData);

    // Some other code
}

// Some other code
pushPageDataToExternalSystem();
Enter fullscreen mode Exit fullscreen mode

In this case, you can defer the initialization and execution of the pushPageDataToExternalSystem() function:

  • insert your custom JS code into PHP variable
  • assign the variable to the JS variable using the $escaper->escapeJs() function
  • use the init-external-scripts event to initialize and execute the pushPageDataToExternalSystem() function
<?php $scriptString = <<<script
    <script>
        function pushPageDataToExternalSystem() {
            // Get page data
            const pageData = {
                url: window.location.href,
                title: document.title,
                // ...
            };

            // Push page data to external system
            externalSystem.push(pageData);

            // Some other code
        }

        pushPageDataToExternalSystem();
    </script>
script;
?>
Enter fullscreen mode Exit fullscreen mode
<div id="uniq_id">
    <script>
        (events => {
            const dispatchUserInteractionEvent = () => {
                events.forEach(type => window.removeEventListener(type, dispatchUserInteractionEvent))
                window.dispatchEvent(new Event('init-external-scripts'))
            };
            events.forEach(type => window.addEventListener(type, dispatchUserInteractionEvent, {once: true, passive: true}))
        })(['touchstart', 'mouseover', 'wheel', 'scroll', 'keydown'])

        window.addEventListener('init-external-scripts', () => {
            const html = `<?= $escaper->escapeJs($scriptString) ?>`;
            const el = document.getElementById('uniq_id');
            setInnerHTML(el, html);
        }, { once: true, passive: true });
    </script>
</div>
Enter fullscreen mode Exit fullscreen mode

The function pushPageDataToExternalSystem() will be executed after the user interaction, which contributes to minimizing the DOM size and JS main-thread work.
Obviously, this function is not critical for the performance, but when you apply this approach for tons of JS code, the profit will surprise you.

Largest Contentful Paint (LCP) optimization

The common problem with LCP is the loading of the images. In most cases, it's some Banner image on the Home page or Product image on the Product page.
You can find a lot of posts about LCP optimization:

etc... , and it's really helpful.

What to do if you have already done all of this and still have a problem with LCP?

Here I would like to share with you one tip that is not so common, but beneficial.
You should load the image ASAP. It means that you should load the image with the highest priority.

LCP scores

LCP phase Description
Time to first byte (TTFB) The time from when the user initiates loading the page
until when the browser receives the first byte of the HTML document response.
Load delay The delta between TTFB and when the browser starts loading the LCP resource.
Load time The time it takes to load the LCP resource itself.
Render delay The delta between when the LCP resource finishes loading until the LCP element is fully rendered.

In most cases, the LCP score is high because of the long load delay and load time.

These two metrics are more important than TTFB and render delay.
So, you should focus on them. To determine the LCP resource, you can use the Chrome DevTools. Open the Network tab and check the order of the LCP resource loading.
The LCP resource should be loaded as soon as possible. If you have some other resources loaded before the LCP resource, you should change the loading order.
As faster LCP resources will be loaded, as better load delay and load time metrics will be.

I can't provide you with the exact solution because it depends on your project, but I hope this tip will help you improve the LCP score.
It's better than just a solution, I give you an idea. Do your magic!

Conclusion

There are a lot of things that you can do to improve your performance. I hope this article will help you improve your store's performance.
I will be glad to hear your feedback and ideas. Feel free to contact me if you have any questions.

Top comments (2)

Collapse
 
riconeitzel profile image
Rico Neitzel

That's a nice technical view. Usually these articles are just high-level, not going into technical details. I'm loving it! Thanks for your time to investigate options for speed up Adobe Commerce stores other than Hyvä 😄

Collapse
 
vpodorozh profile image
Vladyslav Podorozhnyi 🇺🇦 🌻

Great article! thx for sharing.

Points regarding "delayed" rendering are quite interesting.