Optimizing images for performance on Shopify

Optimizing images for performance on Shopify

Updated: 18 Sept 2023

Images are key to user experience, especially in ecommerce. It is difficult to sell a product unless a customer can see it. In my previous post, I wrote about how to balance file size and image quality. However, making images smaller doesn't always result in better performance.

In this post, I will focus on how images can impact page load speed and layout shift. Specifically, I’m going to focus on two metrics in the Core Web Vitals:

  • Largest Contentful Paint (LCP)
  • Cumulative Layout Shift (CLS)

The way you implement image loading can have a large impact on both. If you’re not familiar with these metrics, now would be a good time to read the Key web performance metrics in 2022.

Images and LCP Jump to heading

Largest contentful paint, or LCP, is a user-centered metric that reflects page load speed, or the perception of page load speed. It is the time required for the largest item in the viewport to render. If the largest item changes during the load, then the last item is used.

About 77% of LCP elements are images:

Bar chart showing an image is the LCP content type on 82% of desktop pages and 72% of mobile pages, it is text on 17% and 26% respectively, and an inline image on 2% of desktop and 1% of mobile pages.
Data from the 2022 Web Almanac shows that 72% of LCP elements on mobile were images. Source

Ensuring that any LCP image loads quickly is key to improving your LCP.

Lazy vs eager loading Jump to heading

The biggest problem we see in Shopify stores is delay due to lazy-loading the LCP image. Lazy-loading your LCP image means that you have to wait until the page is rendered and the browser runs the IntersectionObserver before it will realize the image is visible and finally requests the image file.

When looking at the top ~8 million pages, nearly 17% of LCP images are lazy loaded. We ran a query on the same public dataset but filtered to Shopify sites. Unfortunately, we’re doing even worse at 59% of Shopify pages! We went one step further to look at the impact to LCP and found the distributions suggest that LCP is about 3 seconds slower on Shopify sites that lazy load the LCP:

Histogram chart showing 2 series with the first, eager loaded LCP images, peaking much earlier in the timeline than the second, lazy loaded LCP images.
Data from the HTTP Archive (September 2022) showing that the distribution of Shopify sites that do not lazy load their LCP image are associated with a 3 second faster LCP.

That delay could be due to a number of factors, but lazy loading still has a significant impact. When I switched our blog header image from lazy to eager loading, our LCP improved by 0.5 seconds. WebPageTest is a great tool for testing before and after you make a change.

Why do we see so many lazy loaded LCP images on Shopify sites? Part of this is due to the nature of how Shopify themes are built. Sections are designed to be reusable across pages and in different locations on a page. Thus, it was difficult to determine if an image would be above the fold or not. Previously, the only way to overcome this problem was with a section setting.

Now, however, we have two new features in Liquid to help you:

  1. The image_tag will set the loading attribute to lazy for images in sections further down the page by default.
  2. You can override this behavior with a new section.index property that helps you better understand where that section is rendered on the page.

To learn more about these new features and see code examples, check out Announcing new Liquid features for better web performance.

In conclusion, don’t lazy load your LCP image. Consider using the new features in Liquid to rely on the default image_tag or programmatically set it with section.index.

On more stable pages like product or blog article, the main product image and blog header image can be set to eager load. If it’s a grid of images, you can eager load the first row and lazy load the rest using the forloop index property, like in this example:

{% comment %} For a grid that is 3 columns wide {% endcomment %}
{% for block in section.blocks %}
{%- liquid
if forloop.index <= 3
assign loading = "eager"
assign loading = "lazy"

{{ block.settings.image
| image_url: width: 300
| image_tag: loading: loading }}

{% endfor %}

Native lazy loading vs 3rd party libraries Jump to heading

At the time of writing, native lazy loading using the <img> loading attribute was supported in browsers for 92% of global users. Also, as I detail in the next section, JavaScript is your most expensive asset. The easiest way to reduce the amount of JavaScript you use is to remove entire 3rd-party libraries. Thus, we recommend using native lazy loading so that 92% of users can receive the most optimal experience. The remainder will fail gracefully as the attribute is ignored in browsers which don’t support it.

Client-side rendering Jump to heading

Front-end JavaScript frameworks like Vue and React have become popular in recent years, and we’ve seen this play out in themes as well. Developers can be eager to build using these frameworks but don’t spend enough time weighing the significant negative performance impact they can have. Typically, these frameworks will send a minimal amount of HTML to the browser which will call a lot of JavaScript in terms of both files and bytes. Then that JavaScript will render the page inside of the browser.

This pattern of client-side rendering significantly delays the first render. JavaScript is your most expensive asset as not only does the browser need to download the file, but it also needs to parse, compile, and execute it. During this time, the main thread is usually blocked causing other issues as well.

Front-end frameworks are not the only cause of this. Many personalization, A/B testing, and external CMS apps will result in the same issue - a significant delay to the content being rendered.

One way you can improve this is to move this work to the server. Your options here are to switch back to HTML and Liquid, at least for components or sections above the fold, or to switch to a server-side rendered framework such as Hydrogen. If you want to manage your own server, you can consider Nuxt (Vue-based) and Next (React-based). Hydrogen, Nuxt, and Next are going to require a lot more developer time so they are larger architecture decisions. You can migrate your most important components back to HTML and Liquid with less effort for shorter-term results.

A second way you may be able to improve this is by preloading the LCP image. Preloading is useful for when you know you need a particular asset for a page, but it won’t be discovered by the browser until quite late in the load (i.e., after downloading other files). Preload can have some major downsides though, so keep reading…

Preloading pitfalls Jump to heading

Preload is often called a footgun because it can result in you shooting yourself in the foot. It hijacks the normal browser behavior to force the download of an asset. This becomes a problem if the file is less important than a render-blocking resource or when the file is never used

Unfortunately, no easy rule exists for knowing ahead of time whether preload will make your site faster or slower. If used in moderation, it can often be beneficial. The only way you will know for sure is through rigorous testing with tools like WebPageTest.

If your site is only using HTML and Liquid, then in most cases, you should not be preloading an image. Preload is most useful for late-discovered assets. For example, font files are usually discovered late because they are declared inside of a CSS file. An image tag is already in the HTML so the browser will already be able to identify it and load it quickly. If the image is a background image, you may want to inline the background-image CSS for that element. Adding a preload may work (test before and after), but also consider that it will add technical debt and you may forget to change it when you change the image in the future.

Fetch priority Jump to heading

Fetch priority is a new, promising feature for LCP images. Browsers prioritize different file types differently. For example, Chrome sets CSS files as high priority by default because CSS is a render blocking resource. Non-blocking JavaScript and images are set to low by default. Once Chrome determines that an image is above the fold, it will change the priority of that file to high. However, that alone won’t always recover the lost time from starting at a low priority.

The fetchpriority attribute can be used to signal to the browser that a file should be treated differently from its default behavior:

alt="A cute dog">

If you’re using the Liquid image_tag, add it to the HTML attribute list:

{{ product
| image_url: width: 200
| image_tag: loading: 'eager', fetchpriority: high }}

Several companies have seen significant improvements to their LCP images by setting the fetch priority to high, including some Shopify merchants. I was able to decrease the LCP of our blog article pages by 0.25-0.5s by setting fetch priority to high for our header image. I applied that learning back to the Dawn theme.

Again, don’t abuse this feature. If you set everything to high priority, then nothing is high priority. Browsers are fairly smart at setting priorities, so only use it for exceptions.

At the time of writing, fetchpriority was available in Chrome and Edge. Check caniuse for the latest support. The good news is that you can safely set it in all browsers. Browsers that have not yet implemented it will simply ignore the attribute.

Images and CLS Jump to heading

Cumulative Layout Shift, or CLS, is the other metric in the Core Web Vitals where images can often be at fault for poor scores. Layout shift is a user-experience metric related to annoyance. When elements are moving around the page, that can lead to higher frustration, for example, attempting to click a link that moves resulting in a mis-click.

The primary culprit related to images is not reserving space for them. When a browser first renders a page, it does not know the height of an image unless it is provided in the HTML or CSS. When the image finally loads, it will then realize what the height is and shift content down to accommodate the image.

To prevent this shift, we can reserve space for that image in a few different ways.

Height and width attributes Jump to heading

First, we can set height and width attributes on the <img> tag, and then in the CSS, set:

img {
width: 100%;
height: auto;

Set the HTML attributes for width and height in pixels. It does not matter that it could be displayed at a different width as the browser is using the aspect ratio inherent with the given width and height.

srcset="dog_300.jpg 300w, dog_600jpg 600w"
height="393" />

Luckily, in Liquid the height will automatically be set for you if you use the image_tag, as shown in this example from Responsive images on Shopify with Liquid:

<!-- Input -->
{{ section.settings.image | image_url: width: 300 | image_tag }}

<!-- Output -->
height="393" />

CSS aspect-ratio Jump to heading

Another option is to use the newer CSS property aspect-ratio to declare the aspect ratio to preserve which in turn sets the height. Here are some example values from the MDN docs:

.my-class {
aspect-ratio: 1 / 1;
aspect-ratio: 16 / 9;
aspect-ratio: 0.5;

If you’re curious about the differences between these two options, check out Jake Archibald’s post Avoiding <img> layout shifts: aspect-ratio vs width & height attributes.

Conclusion Jump to heading

In conclusion, we can improve the user experience of web performance related to images in a few ways:

  1. Never lazy-load your LCP image.
  2. Default to server-rendered HTML for key content, at least above the fold.
  3. Consider using a high fetch priority if your LCP image is easy to identify.
  4. Reserve space for your images so that they do not cause layout shift when they load.

Playing with image sizes and formats is fun, but to realize the most improvements to performance, we must also consider these principles.

Header photo by Alexander Dummer on Unsplash


Read similar articles tagged...

Back to blog