How layout position impacts three big web performance levers

How layout position impacts three big web performance levers

Updated: 18 Sept 2023

We help Shopify merchants improve their web performance and see three common problems related to layout position:

  • Lazy loading images above the fold
  • Asynchronous loading of CSS needed for elements above the fold
  • Not prioritizing the fetch of the the Largest Contentful Paint (LCP) image

Historically, these three issues have been difficult to solve in Shopify themes, so we created some new features to help. This post will help you better understand these performance antipatterns so that you’re ready to use the new features.

What is image lazy loading? Jump to heading

Lazy loading of images is a web performance pattern to help prevent the unnecessary download of files that may not be needed. This helps reduce total bytes downloaded. It also frees the browser to better prioritize more important assets needed for initial render. The opposite of "lazy" loading is "eager" loading. Eager loading is the default behavior for browsers that encounter an <img> tag.

In the case of images, there are two ways to lazy load images:

  • Using the native loading attribute:
    <img src="image.jpg" loading="lazy" />
  • Using a library like lazysizes:
    <script src="lazysizes.min.js" async=""></script>
    <img data-src="image.jpg" class="lazyload" />

Both methods use the IntersectionObserver to observe when an image is close to "intersecting" with the viewport before loading it. This is great for images that are "below the fold" or hidden off screen. The problem comes in when the image is in the viewport on load. The browser will not load the file until after it performs layout and paint. Only then can JavaScript calculate the intersections. This adds a significant delay versus the default eager loading behavior:

Loading diagram showing that lazy loaded images don't begin to queue their download until much further along in the page load process - after render and paint when the IntersectionObserver can finally fire.
Lazy loading images prevents unnecessary bytes from downloading. But, it can also cause large delays for visible images resulting in poor user experience.

If you’ve decided to use lazy loading, you may be asking yourself which is the best method. The benefit of using native lazy loading is that it requires no extra JavaScript dependencies. With Interaction to Next Paint (INP) on the horizon as the next Core Web Vital, you’ll want to get your JavaScript dependencies as lean as possible.

Currently, the main benefit that tools like lazysizes have over the native solution is that they can automatically calculate the sizes attribute for lazy loaded images. The good news is that this has been proposed and accepted as a new feature in the HTML specification. Chrome is releasing a dev trial soon with Firefox and Safari indicating positive intent to ship, at the time of writing.

Unfortunately, for both methods, you need to set the sizes attribute manually for any image that needs to be eagerly loaded. The size cannot be calculated until after paint, leading to the same problem with delays as mentioned above. The sizes attribute is notoriously difficult to get correct. In these cases, the Responsive Image Linter Chrome extension can help you dial it in more accurately. Put in any "bad" value for the sizes attribute, and it will suggest a good value for you. The srcset suggestions can be a bit aggressive - you don’t need a perfectly sized image for every width. It’s faster to have a shorter list of widths to take advantage of caching.

The size of the image doesn’t match the sizes attribute (min-width: 980px) 928px, calc(95.15vw + 15px). At a viewport of 1280x720 the image was 1044 pixels wide instead of the specified 928 (13% difference). The affected viewports are 1000x563-3000x4000. Try using sizes="(min-width: 1120px) 1044px, calc(93vw + 21px)" instead.
Responsive Image Linter can calculate the sizes attribute for you.

The impact of poor lazy loading on performance Jump to heading

Historically, theme developers have not been able to determine section order or position. This caused many themes to apply lazy loading to all images. The result is that 82% of Shopify sites now lazy load their Largest Contentful Paint (LCP) images.

82.4% of Shopify sites lazy load their LCP image. Compared to 24.8% for Wordpress, 14.1% for React, 25.9% for Squarespace, and many more all below 20%.
Shopify sites struggle the most with this antipattern. 82% of pages with image LCP elements lazy loaded them in July 2023. Data is from HTTPArchive and updated from the original published in Lazy-loading LCP images: Why does this antipattern happen? by Estela Franco. At that time, we were at 67%. Spreadsheet with updated queries and data.

This performance antipattern causes the LCP to be delayed by varying amounts, depending on device and network speeds. In this test for improving the Dawn theme last year, switching the article header image to eager instead of lazy loading improved performance by 0.5 seconds:

A comparison of thumbnail filmstrips from loading a website. The eager loaded image shows completion at 3.0s while the lazy loaded one shows completion at 3.5s.
In this WebPageTest comparison, LCP improved by 0.5 seconds when we removed lazy loading from the header image.

In addition, when looking at the broader web through HTTPArchive, the distribution of Shopify sites that lazy load their LCP element is slower than the eager-loaded distribution. The median LCP for the lazy loaded sites is 1.0 second slower than the eager loaded ones. When we go further along the tail, that difference increases to 1.4 seconds at the 75th percentile:

Impact on LCP of lazy-loading the LCP image element showing two distributions - the lazy loaded distribution is noticeably slower than the eager loaded distribution. Shopify mobile page data.
Data from the HTTPArchive for Shopify pages on mobile. To see the full data (including desktop) and queries, see this spreadsheet.

What is asynchronous CSS loading? Jump to heading

Async loading of CSS is a handy pattern considering Shopify themes often need more CSS for theme flexibility. It helps de-prioritize CSS that may not be needed right away. This pattern is closer to a hack than a feature; although it is a handy one. This is what the code typically looks like:

<link rel="stylesheet" href="my.css" media="print" onload="this.media='all'">
<noscript>
<link rel="stylesheet" href="my.css">
</noscript>

How does it work? Browsers will only set the highest download priority for stylesheets that match the "screen" or "all" media attribute. If the media is set to "print", it will set the priority to "lowest". Other higher priority files will download first. Also, the initial render will not use the print styles. Then after the load event fires, JavaScript changes the media to "all" or "screen". At that point, the CSS file priority changes to "highest", and the page is re-rendered once the styles arrive. As a fallback for browsers where JavaScript is disabled, it’s a good idea to add in the standard <link> fallback within a <noscript> tag.

Here is what that process would look like using our simplified render diagram:

The CSS gets put in the download queue right away, but at a low priority. Then after layout, paint, and the load event, its media attribute is changed by JS, triggering an increase in priority for download, then finally a style reflow when the CSS arrives.
Async CSS can help us de-prioritize CSS less important CSS. But, it also causes style reflows. Each CSS file could cause a separate style reflow depending on arrival and if the browser can batch the work.

The impact of poor asynchronous CSS loading Jump to heading

We see many Shopify sites using this pattern causing layout shifts. This impacts Cumulative Layout Shift (CLS), another Core Web Vital. Stated another way, if elements above the fold need those styles, a user will see a flash-of-unstyled-content (FOUC), or what I like to call a flash-of-semi-styled-content. Let’s call it "FOSSC":

Style reflows due to late CSS can also destroy an otherwise perfectly crafted image lazy loading strategy. The IntersectionObserver might "wrongly" detect an image in the viewport only to have late CSS push that image further down the page. Injected components can also do this. The net effect could be that many images are loaded at once even if the final page layout should only have downloaded a few.

What is fetch priority? Jump to heading

Fetch priority is a more subtle concept. The number of files needed to show a website is high, thus smarter ordering of the files in the download queue can result in a faster perceived user experience. Browsers set download priorities based on different file types and contexts. For example, browsers fetch CSS files at the highest priority because they are render-blocking and needed for initial render.

By default, images are fetched at a low priority. Once a browser determines an image is visible and needed sooner than offscreen images, it can change the fetch priority to high:

Images are put in the download queue right away at lowest priority. Then after layout, Chrome detects they are in the viewport and increases the priority to high.
The default prioritization of images is "low". After layout occurs, Chrome updates the prioritization of visible images to "high". The opportunity cost is the amount of time that passes until paint during which the important images could be downloaded sooner than less important assets.

The Fetch Priority API allows us to hint to the browser that we’d like a different priority set for an asset. For example, if we’re almost certain a particular image element will be the LCP element, we can set its priority to high:

<img src="LCPimage.jpg" fetchpriority="high" alt="Important image!">

On the other hand, if we know an asset is not important, we can set its priority to low so that other more important files can download sooner.

Don’t go wild with this feature! If everything is important, then nothing is important, and we’ve made the browser less optimal than its default behavior.

The impact of poor fetch prioritization Jump to heading

In most cases, the impact is a missed opportunity of not getting your LCP image fetched at a high priority from the start. This is about a 0.3s improvement (conservatively) left on the table.

On the other hand, you may use prioritization poorly by setting too many assets with fetchpriority="high". This could de-optimize the loading prioritizations, delaying both FCP and LCP.

Help is here Jump to heading

We now have some new features for Liquid storefronts to better cope with these issues:

  • Default lazy loading for the image_tag for sections further down the page
  • New section properties section.index, section.index0, and section.location for more fine-tuning of image lazy loading as well as async CSS loading.

Check out Announcing new Liquid features for better web performance to learn all about how they work and to see code samples.

Cover photo by Hal Gatewood on Unsplash

CONTENTS

Read similar articles tagged...

Back to blog