What the browser actually does when you lazy-load product images
Native lazy loading is not "load when the image scrolls into view". Browsers start fetching a lazy image when it comes within a distance threshold of the viewport, so it is usually ready by the time the shopper reaches it. Chromium's documented thresholds are generous: roughly 1,250px on fast connections and 2,500px on slower ones. Those numbers matter for product pages, because 1,250px on a phone is well over a full screen of content.
Two consequences follow. First, lazy loading saves less than people expect on short pages: if your whole product page is three screens tall, most of the "lazy" images load on arrival anyway. The real savings come on long collection pages, search results and homepages with dozens of tiles. Second, the cost of a lazy attribute on an above-the-fold image is not the download itself. It is the delay. The browser cannot decide whether a lazy image is near the viewport until it has done layout, which needs CSS, so the request starts late and outside the preload scanner's early pass.
That is why the right question is never "should we lazy load?" It is "which images, on which template, at which viewport, are candidates for the first screen?" Anything that might be the first thing a shopper sees gets eager loading. Everything else is lazy.
Where the fold sits, template by template
There is no single fold. It moves with the template, the device and whatever sits above the product imagery: announcement bars, sticky headers, promo banners and cookie notices all push images down on some pages and not others. Work it out per template, at the smallest common phone viewport and at a common laptop size, because the LCP element on mobile is often a different image from the one on desktop.
| Template | Likely LCP image | Load eagerly | Lazy-load |
|---|---|---|---|
| Product page | First gallery image | Image 1, plus fetchpriority="high" | Gallery images 2+, reviews photos, related products |
| Collection page | First tile, or a collection banner if you have one | First visible row(s) on mobile and desktop | Every tile below those rows |
| Search results | First result tile | Same rule as collections | Remaining results, "load more" batches |
| Homepage | Hero banner or lifestyle image | Hero only | Featured collections, lookbook, blog cards |
| Mega menu / drawers | Never LCP | Nothing | All menu imagery (it is hidden on load) |
| Cart and upsell blocks | Usually text | Line-item thumbnails if above the fold | Cross-sell carousels |
The homepage and collection page are where the defaults most often go wrong. A homepage whose hero is a slideshow can have a different LCP element depending on which slide renders first, and a collection page with a text-only header on desktop may promote the first product tile to LCP on mobile. Check both viewports before you set the policy.
A promo strip or app-download banner that only appears on phones can push the first gallery image below the fold on small screens, or push it back up when the banner is dismissed. Base the policy on what a first-time visitor sees, because that is who the field data measures.
Collection grids: decide by position, not by component
Collection and search grids are where lazy loading earns its keep, and also where a blanket setting does the most damage. A product-card component does not know where it is on the page. If the card template hard-codes loading="lazy", the first row of every collection is lazy. If it hard-codes eager, a 48-product collection requests 48 images on arrival.
The fix is to pass the card its position and decide there. A simple rule is to load eagerly the number of tiles that fit in the first screen at your widest common layout, and lazy-load everything after that:
{% for product in collection.products %}
{% if forloop.index <= 4 %}
{% assign loading = 'eager' %}
{% else %}
{% assign loading = 'lazy' %}
{% endif %}
{% render 'product-card', product: product, loading: loading %}
{% endfor %}
Four covers a two-column mobile grid (two rows visible) and a four-column desktop grid (one row visible). If your desktop grid is five or six columns wide, raise the number to match. Resist setting it much higher: every eager image competes for bandwidth with the one that actually becomes LCP. Give fetchpriority="high" to the first tile only, not to all of them. Priority only works when it is scarce.
The chart makes the point that "all lazy" and "first four eager" end up requesting a similar number of images on arrival, because the load-ahead distance pulls in the next few rows either way. The difference is timing. In the all-lazy version the first row waits for layout. In the position-aware version it is requested straight from the HTML.
Many modern Shopify themes already make this call by section position rather than per card, lazy-loading images only in sections further down the template. That works for banners and one-image sections. It does not help a single grid section that holds both the first row and the fortieth tile, so check how your theme handles the grid itself.
Galleries, carousels and zoom images
Product galleries hide most of their images. Slides two to eight sit off-canvas in a carousel, and the full-resolution zoom image only matters if the shopper asks for it. Each needs a different treatment.
- Slide 1: eager,
fetchpriority="high", rendered in the server HTML. If your gallery is built entirely in JavaScript, the first image does not exist until the script runs. Server-render it, or at minimum add a preload for it. - Slides 2 and beyond:
loading="lazy". Modern Chromium applies the same load-ahead logic to images sitting off-screen in a horizontal scroll container, so the next slide is usually fetched before the swipe finishes. If your carousel hides slides withdisplay: noneinstead of positioning them off-canvas, lazy images inside it will not load until they are shown. That can make a swipe land on a blank frame, so test on a throttled connection. - Thumbnail strip: small and cheap. Eager is fine if the strip is visible on load, but keep the files genuinely small. A thumbnail rail that downloads full-size originals scaled down in CSS is one of the most common sources of wasted bytes on product pages.
- Zoom and lightbox images: load on intent, not on page load. Request the high-resolution version when the shopper hovers, taps or opens the lightbox. Our product image zoom guide covers the resolution side of that trade-off.
Variant swatches are the edge case. When a shopper picks a colour, the gallery swaps to that variant's images, and a shopper who changes colour on arrival will wait if those images were lazy. If most traffic lands on a default variant and some shoppers switch straight away, prefetch the first image of each variant after the page has loaded. That is cheap, and it removes the blank moment after a swatch click.
Native attribute or JavaScript lazy loader?
Before browsers supported loading="lazy", themes lazy-loaded with JavaScript: the real URL sat in a data-src attribute and a script swapped it into src as the image approached the viewport. Plenty of themes and apps still ship this pattern, sometimes alongside the native attribute. It has costs that the native version does not.
Script-based (data-src)
- No image request until the script downloads, parses and runs
- The preload scanner cannot see the URL, so eager treatment is impossible
- Images that depend on scroll events may never render for crawlers
- A script error leaves every product tile blank
- Often combined with a placeholder that shifts layout when swapped
Native loading="lazy"
- The browser schedules requests with no script dependency
- Eager images are discovered straight from the HTML
- Supported by Google's rendering for indexing
- Degrades safely: at worst an image loads early
- Works with srcset and sizes without extra code
The search side is worth spelling out. Google's guidance is that content should not depend on user actions such as scrolling or clicking to load. Its renderer does not scroll the way a shopper does. Native lazy loading and viewport-based loaders using IntersectionObserver are generally fine. Loaders that wait for a scroll event are not, and product images that never load for the renderer cannot rank in image search. If you want your product photos to appear in Google Images and Shopping surfaces, the URL should be in a real src or srcset in the served HTML. Our product image SEO guide covers the alt text and file name half of that.
If your theme uses a script loader, the usual migration is to move the URL back into src, add loading="lazy" to below-fold images, and delete the library. Check apps as well as the theme: review widgets, recently-viewed carousels and upsell blocks often bring their own loader.
Placeholders, layout shift and consistent framing
Lazy loading makes the space an image will occupy visible before the image itself, which means Cumulative Layout Shift and lazy loading are the same problem seen from two angles. Every lazy image needs its box reserved before it loads: explicit width and height attributes, or a CSS aspect-ratio on the container. Without that, each tile that arrives pushes the grid down and the shopper's thumb lands on the wrong product.
What fills the box while the image loads is a design decision with performance consequences:
| Placeholder | Cost | Verdict |
|---|---|---|
| Solid colour matching your product background | Zero bytes, one CSS rule | Best default for catalog grids |
| Blurred low-quality preview (LQIP) | Small, but extra markup or requests | Nice on editorial and lifestyle imagery |
| Spinner or animated shimmer | Script or animation cost, visual noise | Avoid on dense grids |
| Nothing (transparent box) | Zero, but white flashes on coloured pages | Acceptable on white-background stores |
Two details are worth knowing. Chrome ignores very low-detail images as LCP candidates, so a blurred placeholder does not "count" as your LCP. The metric waits for the real image, and a placeholder cannot fake a good score. The upside is that it cannot hurt your score either. Second, a solid-colour placeholder only looks clean if your product images actually share that background. If half the catalog sits on #FFFFFF and half on a warm off-white, any single placeholder colour flashes on one half.
Consistency pays off here as well as for merchandising. When every image in a grid has the same aspect ratio, the same plate colour and a consistent product scale, you can reserve every box with one CSS rule and fill it with one colour, and the grid looks finished before a single image has loaded. That is part of why Retouchable's product outputs end with a deterministic framing step that paints an exact plate colour and scales each cutout to a fixed rule: the consistency that makes a grid look professional also makes it easy to load gracefully.
Verify it in field data, not a single lab run
A Lighthouse run on your laptop tells you whether the policy is wired up correctly. It does not tell you whether shoppers got faster pages. Use the lab to check the mechanics and field data to judge the outcome.
- Check the HTML, not the DOM. View the page source (not the inspector, which shows the page after scripts have run) and confirm the first gallery image and the first grid row have a real
srcand noloading="lazy". - Find the LCP element at both viewports. The Performance panel in Chrome DevTools names the LCP element. Run it at a phone size and a laptop size, on every template in the table above.
- Read the Lighthouse flag. Lighthouse explicitly warns when the LCP image was lazily loaded or discovered late. Treat that warning as a bug, not a suggestion.
- Throttle and swipe. On a throttled mobile profile, swipe the gallery and scroll a long collection. Blank frames mean the policy is too aggressive, usually because of hidden slides or a script loader.
- Segment field data by template. Search Console's Core Web Vitals report groups similar URLs, and real-user monitoring can split LCP by page type. A sitewide average hides a collection template that regressed while product pages improved.
- Wait for the 28-day window. Chrome UX Report data is a rolling 28-day window at the 75th percentile, so a change takes about four weeks to show fully. Judge it then, not the next morning.
Eager for anything that could be the first screen on any common viewport, fetchpriority="high" on the single most likely LCP image, native lazy loading for everything else, a reserved box and a flat background colour on every image. Most stores that follow that sentence never need a lazy-loading library again.
Lazy loading is one lever among several. File weight, format and correctly sized variants decide how long the eager images take once they are requested. Our guides to srcset and sizes and image CDN setup cover those next steps.