Lazy Loading the Right Way: A Shopify Developer's Field Guide
The nuance most Shopify lazy loading guides miss. Why your LCP image must never be lazy, the forloop.first pattern for product grids, and the fetchpriority plus preload gotcha that silently...
The lazy loading mistake that costs stores their LCP score
Every Shopify theme in 2026 ships with lazy loading on by default. The Dawn-based themes, the paid ones from the Theme Store, the custom builds your last agency put together. They all sprinkle loading="lazy" across every image template and call it a day. And then merchants open PageSpeed Insights, watch their Largest Contentful Paint bar glow red, and wonder why their hero image took 4.2 seconds to render on a device with gigabit fibre.
The answer is almost always the same. Somewhere, the LCP image is lazy-loaded. Sometimes because the theme developer was copy-pasting patterns. Sometimes because a Shopify app injected its own wrapper. Sometimes because the merchant dragged a new image section to the top and the theme assumed everything below position three is below the fold. Lazy loading is a surgical tool. Applied indiscriminately, it turns a performance win into a silent conversion killer.
This guide is the one we wish we could hand every developer inheriting a Shopify theme. It covers the forloop.first conditional pattern we use to mark exactly one image eager inside a product grid, the fetchpriority rule that Shopify's own performance team recommends, the double-download trap that catches teams who combine preload with fetchpriority, the font loading pitfall that causes invisible text on slow mobile networks, and the content-visibility CSS trick that makes deep collection pages feel instant. It is the long version of a conversation we have had with half the merchants who come to us for image work.
What Shopify already does for you, and where it stops
Shopify's Liquid team quietly shipped a set of image optimisations in 2023 that most theme developers still do not fully understand. When you render an image with the image_tag filter, Shopify automatically sets loading="lazy" on any image that appears after the first three sections of the template. The assumption is that the first three sections usually make up the above-the-fold area. If you do not specify loading yourself, the platform decides for you.
That default is better than nothing. But it is also the source of a lot of quiet regressions. If a merchant adds an announcement bar, a sticky header section, and a promo banner before the main product media section, the featured product image is now the fourth section, and Shopify will happily lazy load it. You will have no warning. The theme will keep rendering, Lighthouse will start complaining, and nobody will know why the LCP metric spiked after the last theme customisation.
The official Liquid image_tag filter accepts a rich set of parameters that we lean on constantly. A typical call looks like this.
{{ product.featured_image | image_tag:
loading: 'eager',
fetchpriority: 'high',
widths: '400, 600, 800, 1000, 1200, 1600',
sizes: '(max-width: 749px) 100vw, 50vw',
width: 800,
height: 800,
class: 'product-hero' }}
That single filter call produces a responsive srcset, a sizes attribute, the fetchpriority hint, explicit width and height to prevent layout shift, and a class you can target. It is the correct baseline for an LCP image. What it does not do is think for you. You still have to know which image is your LCP candidate, and that decision changes per template and per viewport.
The rule that supersedes every other rule: never lazy-load your LCP image
This one sentence would have saved us dozens of audits. The single most expensive mistake in Shopify image optimisation is putting loading="lazy" on the image that paints first in the viewport. The browser reads the lazy attribute and defers the network request until the image is near the viewport. If that image is already in the viewport on first paint, you have just told the browser to wait for something it needs immediately.
Even worse, adding fetchpriority="high" to a lazy-loaded image does not save you. The lazy attribute still delays the initial fetch decision. The browser has to first determine that the image is near the viewport, and only then does the high priority kick in. In practice, you lose hundreds of milliseconds, and on a slow mobile network the damage is measured in full seconds. Lighthouse flags this specifically as "Largest Contentful Paint image was lazily loaded" and the Chrome performance team's own guidance is unambiguous. Remove loading="lazy" from your LCP image entirely and use fetchpriority="high" instead.
On a product page, the LCP image is almost always the first product media. On a collection page it is usually the first product card. On a homepage it is the first hero slide or banner. On an article page it is the featured image. The job of a good theme developer is to encode that logic so the template does the right thing without the merchant having to think about it.
The forloop.first pattern for product grids
This is the pattern that most SERP results for Shopify lazy loading skip entirely. Every guide tells you that the hero is eager and everything else is lazy. Almost none of them show you what to do inside a repeater. Collection pages, related products, upsell carousels, these all render images inside a Liquid for loop. If you lazy load every image inside the loop, your first product card lazy loads too, and on mobile that first card is frequently your LCP candidate.
The fix is a conditional that flips the loading attribute only for the first iteration of the loop, and only when the section itself is above the fold. Here is the pattern we deploy on collection templates.
{% for product in collection.products limit: section.settings.products_per_page %}
{% assign is_lcp_candidate = forloop.first and section.index <= 2 %}
<a href="{{ product.url }}" class="product-card">
{{ product.featured_image | image_tag:
loading: is_lcp_candidate | default: false | ternary: 'eager', 'lazy',
fetchpriority: is_lcp_candidate | default: false | ternary: 'high', 'auto',
widths: '200, 400, 600, 800',
sizes: '(max-width: 749px) 50vw, 25vw',
width: 400,
height: 400,
class: 'product-card__image' }}
<h3>{{ product.title }}</h3>
</a>
{% endfor %}
Shopify Liquid does not have a native ternary operator in all contexts, so teams who are comfortable with the newer syntax can use if...else blocks instead. The principle is the same. Only one image in the loop gets the eager and high priority treatment. Every other image defers until the user scrolls near it. The section.index check is what protects you from the merchant scenario where a collection is rendered inside a third or fourth section on a custom page.
On a product page template, the equivalent pattern targets the product gallery. You want the first gallery image eager and every thumbnail and secondary image lazy. The same forloop.first check applies, with an added wrinkle for product pages that hide gallery thumbnails on mobile. On those, the thumbnails are out of viewport entirely and should remain lazy regardless.
This is the pattern Shopify's own performance team uses in reference implementations, and it is the single biggest lift you can give a theme that currently lazy loads every image in sight.
The fetchpriority and preload gotcha that double-downloads hero images
Once you learn about fetchpriority, the next instinct is to also add a <link rel="preload"> in the head for extra insurance. This is where teams shoot themselves in the foot. If your preload tag points to a specific image URL and your img element renders a different srcset candidate because the viewport picks a different breakpoint, the browser fetches both images. You pay the bandwidth twice. You might even trigger two LCP candidates. Lighthouse will not necessarily flag this because both resources load fine, but your real user metrics will suffer.
The modern browser has an LCP image discovery pass that handles most of this for you. Chrome's rendering team explicitly recommends relying on fetchpriority="high" on the img element itself and skipping the preload link in most cases. Preloading is only the right answer when the image is injected by JavaScript or is inside a CSS background, because in those cases the browser cannot discover the resource during the initial HTML parse.
If you do need a preload for a CSS background hero or a slider that hydrates after first paint, use the imagesrcset and imagesizes attributes on the link tag to match your img element's responsive candidates exactly. That way, the preloaded image and the eventually rendered image are the same file.
<link rel="preload"
as="image"
href="{{ section.settings.hero_image | image_url: width: 1600 }}"
imagesrcset="{{ section.settings.hero_image | image_url: width: 400 }} 400w,
{{ section.settings.hero_image | image_url: width: 800 }} 800w,
{{ section.settings.hero_image | image_url: width: 1600 }} 1600w"
imagesizes="100vw"
fetchpriority="high">
And critically, if you preload an image, do not also add fetchpriority="high" to the img element. Pick one. Preloads with high priority already tell the browser this is urgent. Doubling the signal does not double the speed, but it can double the confusion when something goes wrong.
Fonts are the silent LCP killer nobody talks about
Your hero image being eager is only half the story. If your hero section includes a headline in a custom font, the text portion of LCP depends on when that font arrives. The default browser behaviour, in the absence of any font-display hint, is to hide the text for up to three seconds waiting for the custom font. This is the Flash of Invisible Text problem, and it wrecks LCP just as thoroughly as a lazy-loaded image.
The fix is font-display: swap, which tells the browser to render text immediately in the fallback font and swap in the custom font when it arrives. This is free performance. It is also where Shopify's hosted fonts get awkward. When you use the font_face filter on a font from Shopify's Font Library, the generated @font-face rule controls the font-display value. Modern Dawn-based themes include font_display: 'swap' in their font loading, but older themes often do not. Check the output of your theme's font loading before you trust that swap is applied.
{{ settings.type_header_font | font_face: font_display: 'swap' }}
{{ settings.type_body_font | font_face: font_display: 'swap' }}
If you are loading fonts from Google Fonts, which we generally advise against for Shopify stores because the extra origin costs you a DNS and TLS handshake, at minimum append &display=swap to the CSS URL. If you are self-hosting fonts, which is what we do on any performance-critical build, you control the @font-face block directly and can add font-display: swap there.
The secondary pitfall is layout shift when the custom font finally arrives and replaces the fallback. The two fonts almost never have identical metrics, so the text reflows, and that reflow counts against Cumulative Layout Shift. The fix is the modern CSS font metric override properties, specifically size-adjust, ascent-override, and descent-override, which let you tune the fallback font so it occupies almost exactly the same space as the custom font.
@font-face {
font-family: 'Custom Brand Sans Fallback';
src: local('Arial');
size-adjust: 105%;
ascent-override: 92%;
descent-override: 23%;
line-gap-override: 0%;
}
body {
font-family: 'Custom Brand Sans', 'Custom Brand Sans Fallback', sans-serif;
}
Tools like Google's fallback font generator or Malte Ubl's metric calculator give you the right numbers for a given font pair. This is the kind of polish that separates a theme scoring 85 on PageSpeed from one scoring 97.
The content-visibility trick for deep collection pages
Shopify stores with three hundred products on a single collection page have a rendering problem that lazy loading alone cannot solve. Even if every image below the fold is deferred, the browser still has to lay out and paint every product card. On a mid-range Android phone, that is a lot of work. Scroll performance stutters, INP scores sink, and the page feels heavy even after everything has loaded.
The modern answer is the CSS content-visibility: auto property, which became Baseline available in late 2024 and is now safe to use across all major browsers. It tells the browser to skip rendering work entirely for elements that are not near the viewport. When the user scrolls close, the browser renders them on demand. The reported rendering improvements are dramatic, with web.dev citing cases where a collection-style page went from 232 milliseconds of render time to 30 milliseconds.
The critical companion property is contain-intrinsic-size, which tells the browser how much space to reserve for the skipped content. Without it, the scrollbar jumps as content renders and unrenders. With it, the page scroll behaves normally.
.product-card {
content-visibility: auto;
contain-intrinsic-size: 400px 500px;
}
.collection-row {
content-visibility: auto;
contain-intrinsic-size: 1200px 600px;
}
Accessibility is preserved. The off-screen content remains in the DOM and the accessibility tree, so screen readers and search engine crawlers still see everything. The property only affects rendering, not availability. The only real caveat is that contain-intrinsic-size should be a reasonable estimate. If you under-specify it, anchor links and scroll restoration can jump. Measure your actual product card dimensions at each breakpoint and set accordingly.
This pairs beautifully with lazy loading. Lazy loading defers the image network request. Content-visibility defers the layout and paint work. Together, they make a 300-product collection page feel like a 30-product one.
Putting it together on a real theme
When we audit a Shopify theme for image performance, we run through a short checklist that mirrors this article. Is the LCP image eager with fetchpriority high. Is every image inside a product grid using the forloop.first conditional for the first card in the first above-fold section. Is there a stray preload link that conflicts with the img element's own priority hints. Are fonts loading with font-display swap and fallback metric overrides. Are deep product grids using content-visibility auto with sensible intrinsic sizes.
Nine times out of ten, at least three of those items are wrong on a theme we are inheriting. Fixing them pushes a typical mid-size Shopify store from an LCP around 3.5 seconds to one under 2 seconds on mobile, with no app installs, no CDN changes, no paid tooling. Just Liquid and CSS written by someone who knows where the traps are.
Where WitsCode comes in
This is the work we do every week. WitsCode runs a Shopify image and LCP optimisation service for merchants who want Core Web Vitals passing grades without ripping their theme apart. We audit your templates, rewrite the image_tag calls with the correct eager, lazy, and fetchpriority hints, deploy the forloop.first patterns on your collection and product grids, self-host and tune your fonts with swap and metric overrides, and apply content-visibility to deep-scroll pages. The typical engagement takes a week and moves LCP by a second or more on real user devices.
We call ourselves the last-mile developer for vibe coders because we pick up where the theme builder, the AI generator, and the page builder stop. Lazy loading is exactly the kind of last-mile detail that AI tools do not get right yet, because the correct answer depends on which image is your LCP candidate and no AI can infer that from a prompt. It takes a human who has read the spec, measured the real user metrics, and written the Liquid conditional. If you want your theme to actually load the way it looks on the designer's laptop, that is the conversation to start. We are at witscode and would be glad to take a look at your store.
Get weekly field notes.
Practical writing on shipping products, straight to your inbox. No spam.
Need help with this?
Shopify Development
We design and build web apps, MVPs, and SaaS products. Talk to us about what you are working on.
Talk to usWant to discuss ecom for your business?
Start a project and we'll talk through where you are, what's working, and the highest-leverage moves for the next 90 days.

