Speed Optimizing Shopify for Core Web Vitals

When I set out to speed optimize moderntribe.com, a site my wife owns with her brother, I didn’t know much about Shopify, and the Google Core Web Vitals standards were brand new. Actually, they were released after I got started. I’m now proud to say the site passes and feels super-fast!

Fair warning, I am not an expert performance engineer, just a self-taught amateur with some technical background. Not everything in this guide has been extensively tested.

shopify-core-web-vitals-pagespeed

 

What I wish I knew now

Setup

Tooling

Techniques

Ordering JS and CSS

Rolling my own lazyloader for scripts

Loading Klaviyo differently on a per-case basis

Hacking content_for_header and loading scripts on a per-page basis

Preloading and limiting fonts

Yotpo fast loading scripts

Lazyload everything with lazysizes

Replacing the District theme Zoom functionality with Magic Zoom Plus

End Result

Conclusion

 

What I wish I knew now

  • CWV standards are not being considered as a Google ranking signal yet and won’t be until the end of 2021.
  • There is a Shopify app that does much of what I did to optimize the site, as well as providing some support for critical CSS inlining, a powerful technique that is still relatively new (there is a Wordpress extension that will do it now, but this is the first Shopify app to my knowledge). That app is called Hyperspeed, I’m not endorsing it because we didn’t use it in the end, but if you’re not comfortable with some pretty hacky code changes that would be a good thing to try.


Setup

  • District theme
  • Yotpo
  • Klaviyo
  • A bunch of other apps that were not impacting rendering so much
  • Lazyload and image compression were already implemented

 

Tooling

  • I used GTMetrix to monitor daily, especially early in the process. Unfortunatley they do not yet have the CWV standards instrumented.
  • I used a Chrome extension to check on Core Web Vitals. In the end it turned out this was not working super well, and sometimes returned false readings on Largest Contentful Paint (LCP).
  • Of course, I used Pagespeed Insights and Search Console.
  • Chrome DevTools turns out to have a way to check when LCP occurs and what element is registered as the largest, and also to check for content shifting that would impact Cumulative Layout Shift (CLS). Read more here.


Techniques 

  • Ordering scripts and CSS
  • Rolling my own lazyloader for scripts
    • Loading Klaviyo differently on a per-case basis
  • Hacking content_for_header and loading scripts on a per-page basis
  • Preloading and limiting fonts
  • Yotpo fast loading scripts
  • Lazyload everything with lazysizes
  • Replacing the District theme Zoom functionality with Magic Zoom Plus

 

Ordering JS and CSS

I came across this article that pretty much said, Shopify is based on liquid, which is a template language, no scripts need to go in the <head>, everything should go at the bottom of the body. So I was ruthless in moving third party scripts. Of course their developers want them in the head!

The only script I ended up with in the head was lazysizes, which is recommended to load immediately after CSS. That, and a few async scripts loaded directly by installed apps and by Shopify, via content_for_header (more on that in a minute).

Inline scripts should generally go after external scripts to give them a chance to start loading.

Critical CSS means loading the CSS needed to paint the above the fold view inline and then loading the rest of the CSS later. I tried a few ways of doing this, both using Chrome DevTools and some third-party tools, but I was ultimately unable to get it to work and in the end I didn’t need it. Hopefully this will become a Shopify standard in the future. So the CSS files go right at the top, under some preloads (more on that later too). 


Rolling my own lazyloader for scripts

It seems like Shopify doesn’t really respect the defer tag on scripts. In particular, the Yotpo, Bing, and Klaviyo scripts (which were in my theme code) would load very early regardless of defer tags. I can’t say for sure whether this was impacting speed but it sure looked and seemed like it. I ended up taking a page from lazysizes and other lazyloaders I was working with and changed the “src=” on the scripts to “data-src=” and then used a little code to change it back after onload. I put this code near the bottom of the body:

 <script type="text/javascript">function init() {

var scriptDefer = document.getElementsByTagName('script');

for (var i=0; i<scriptDefer.length; i++) {

if(scriptDefer[i].getAttribute('data-src')) {

scriptDefer[i].setAttribute('src',scriptDefer[i].getAttribute('data-src'));

} } }

window.onload = init;</script>

 

This worked!

In some cases code was loading through a liquid item (not sure what it’s called) like this:

    {% include 'script' %}

In order to add the data-src you can just change that to something like:
<script data-src="{{ 'script.js' | asset_url }}"></script>

If you have inline code you want to treat the same, you can create a new blank asset with JS type, paste in the inline code (without the <script> tags) and then call it as above. I did this for the Bing UET tag, though that was probably not totally necessary.

 

Loading Klaviyo differently on a per-case basis

The one script I could not load after onload without causing problems was Klaviyo. Klaviyo has an external script you need to call on every page, but then has inline scripts you need to add for different functionality. In particular we were using an inline script to add a “notify me when this item is back in stock” functionality. So this creates a dependency between the external script and the inline code. I tried basically everything, using callbacks, etc. It seemed to be the case that the inline code needed the external script to have not only loaded but have executed something. In the end I compromised—I loaded Klaviyo with data-src (the custom lazyload) for all pages except where an item was out of stock. For those pages I loaded it the traditional way, along with the inline code. The code looks like this:

 

{% if template == 'product' %}

            {% assign outofstock = false %}

            {% for variant in product.variants %}

                        {% if variant.available == false %}

                                    {% assign outofstock = true %}

                        {% endif %}

            {% endfor %}

    {% if outofstock == true %}

                        <script src="https://a.klaviyo.com/media/js/onsite/onsite.js" defer></script>   

                        <script src="{{ 'klaviyo2.js' | asset_url }}" defer></script>

            {% else %}

            <script data-src="https://a.klaviyo.com/media/js/onsite/onsite.js" defer></script>   

            {%endif%}

{% else %}

            <script data-src="https://a.klaviyo.com/media/js/onsite/onsite.js" defer></script>   

{%endif%}   

 

Hacking content_for_header and loading scripts on a per-page basis

Another thing I came to understand is that many Shopify apps load scripts onto your pages, and to make this idiot-proof they do it so that they don’t even show up in your template code. They come through {{ content_for_header }}, which is required to be in the header or the page won’t load. You can see these scripts if you inspect the page or look at the source code. Unfortunately, they load these scripts on every page, regardless of whether they’ll be required. So a script for an Instagram feed that was only on the index page was loading on every page. I didn’t see any indication that these scripts were blocking render, and they’re probably all async, but even async scripts can slow things down. I opted to remove unnecessary scripts on a per page basis.

I used some case logic to define cases for index, products, etc, and then just deleted the unnecessary scripts from content_for_header. This will break over time as scripts change, but all that will happen is the scripts will again load where we don’t need them—it won’t impact anything other than maybe performance. It’s inelegant, but at the time I couldn’t find an app to do this for me. 

I used this code down near the bottom of my <head>. The capture command is basically copying the text from that (uneditable in the code editor) liquid element and letting me edit it through remove. The backslashes need to be escaped (\/).

{% capture h_content %}

  {{ content_for_header }}

{% endcapture %}

{% case template %}

  {% when "index" %}

  {{ h_content | remove: "https:\/\/www.ndnapps.com\/ndnapps\/sociallogin\/js\/frontend\/app.20190227.js?shop=moderntribe.myshopify.com" | remove: "https:\/\/static.cdn.printful.com\/static\/js\/external\/shopify-product-customizer.js?v=0.17\u0026shop=moderntribe.myshopify.com" | remove: "https:\/\/accessories.w3apps.co\/js\/accessories.js?shop=moderntribe.myshopify.com" | remove: "https:\/\/app-cdn.productcustomizer.com\/assets\/storefront\/product-customizer-v2-958e943c79a0494e5cc60b88262c1f95117a47a84641e7d766853727b6cdf3f0.js?shop=moderntribe.myshopify.com"}}

  {% when "product" %}

  {{ h_content | remove: "https:\/\/instafeed.nfcube.com\/cdn\/361aab2843b4bd4cb22b16ba3c8ad053.js?shop=moderntribe.myshopify.com" | remove: "https:\/\/www.ndnapps.com\/ndnapps\/sociallogin\/js\/frontend\/app.20190227.js?shop=moderntribe.myshopify.com"}}

  {% when "article" or "collection" or "blog" %}

  {{ h_content | remove: "https:\/\/instafeed.nfcube.com\/cdn\/361aab2843b4bd4cb22b16ba3c8ad053.js?shop=moderntribe.myshopify.com" | remove: "https:\/\/www.ndnapps.com\/ndnapps\/sociallogin\/js\/frontend\/app.20190227.js?shop=moderntribe.myshopify.com" | remove: "https:\/\/static.cdn.printful.com\/static\/js\/external\/shopify-product-customizer.js?v=0.17\u0026shop=moderntribe.myshopify.com" | remove: "https:\/\/accessories.w3apps.co\/js\/accessories.js?shop=moderntribe.myshopify.com" | remove: "https:\/\/app-cdn.productcustomizer.com\/assets\/storefront\/product-customizer-v2-958e943c79a0494e5cc60b88262c1f95117a47a84641e7d766853727b6cdf3f0.js?shop=moderntribe.myshopify.com"}}

  {% else %}

  {{ content_for_header }}

{% endcase %}

 

Preloading and limiting fonts

I’m not really sure if this did anything but theoretically it makes sense to preload fonts so the browser doesn’t have to read through the CSS to find they are required before they start loading. We had a lot of fonts, 5-6 on each page, so I also removed some fonts in the theme editor on mobile. I also set up some preconnects where I saw connections taking a long time in the waterfall on GTMetrix. This is the very first thing in the <head>. Here is the (abbreviated) desktop code:

<link rel="dns-prefetch" href="https://fonts.shopifycdn.com">

<link rel="preload" href="https://fonts.shopifycdn.com/montserrat/montserrat_n4.1d581f6d4bf1a97f4cbc0b88b933bc136d38d178.woff2?h1=bW9kZXJudHJpYmUuY29t&hmac=24456d3df8d5e9cdeba6d77a66ed60dccc934b7a8a7c791ae251523379b6471a" as="font" type="font/woff2" crossorigin="anonymous">

<link rel="preconnect" href="https://pay.shopify.com">

<link rel="preconnect" href="https://cdn.shopify.com">

 

Yotpo fast loading scripts

I think Yotpo may have actually changed their installation instructions to use these fast loading scripts, but I swapped them in on our product pages since we had the old ones. Seems to have made a difference.

https://support.yotpo.com/en/article/shopify-fast-loading-widgets

 

Lazyload everything with lazysizes

One problem for us was that we had a huge menu. Originally this was done with an app, but we converted it to a traditional mega-menu since the app we were using was taking a long time to load. Still, it’s a huge amount of html. So I put class ”lazyload” on the elements that were not visible on the page. Seemed to speed up the rendering quite a bit.

I ended up applying that class to pretty much everything under the fold… The footer, various sections on product pages and on the index page, the whole mobile nav, etc. Just digging through the Shopify code editor and adding that class to big blocks.

As the pages got faster I started to get dinged for CLS (content shifting). I had missed applying lazyload to one of the sections so it was rendering above the fold and shifting down. Once I added that class lazyload to the section I missed, the problem went away. I couldn’t find any mention of doing this in the lazysizes documentation but I’m assuming I just didn’t look hard enough. I see that some of the speed optimization apps are also lazy loading some of the below-the-fold sections.

 

Replacing the District theme Zoom functionality with Magic Zoom Plus

So, that was a lot of work and a lot of piecing together different information about Shopify performance optimization. Still, I kept seeing that LCP was not below the 2.5s threshold on PageSpeed Insights (for desktop-- you can pretty much ignore the mobile stats since they’re based on slow 3g unless you happen to have a lot of customers with that kind of connection). I also saw that although the LCP was coming down in Search Console it was still ~3s, specifically on the product pages. Very frustrating!

I was using a browser extension to check LCP and CLS, and sometimes it would pass and sometimes not on the product pages. Turns out it was missing something most of the time.

I ended up using Chrome DevTools (actually, it was Brave DevTools) to diagnose the issue. I could see that the element identified for LCP was the main product image (as expected), but the LCP was detected quite a while after onload. In fact, it was detected quite a bit after the image appeared on the page. Very strange!

It turned out that the main product image was shifting an almost imperceptible couple of pixels to the left, well after it had appeared. I could see this happening but could not figure out why. After messing around with various settings (zoom, lightbox, etc) I still could not fix it. I decided to try installing a different zoom app (Magic Zoom Plus), in the hopes that this would override whatever tiny bug was in the theme code causing the issue. It worked!

I can now see that LCP comes immediately after the image loads on the page, and our LCP time is down in the 1.5s range for product pages on desktop. Finally!

 

End Result

<head>

Font pre-loading and resource pre-connecting

CSS

Lazysizes

Meta / Social Tag inclusions
Hacked Content_for_Header

</head>

<body>

Actual body content

Theme critical scripts

Scripts with data-src

Inline code to lazyload data-src scripts

Inline code required for the theme to function

</body>

 

Conclusion

I probably spent way too much time on this and did a bunch of stuff that was not totally necessary. I also probably could do more. It’s good enough. I hope this helps someone. Good luck!

2 comments
Back to blog

2 comments

GENIUS!!!!!!

Laurie Kritzer

WOW I’m so impressed!!!!

Amy Becker

Leave a comment

Please note, comments need to be approved before they are published.