> ## Content Index
> Fetch the complete content index at: https://debugly.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# Your Lazy-Loaded Hero Image Is Why LCP Is Four Seconds
- URL: https://debugly.dev/shopify-lcp-hero-image-lazy-loading/
- Published: 2026-10-09T11:24:00.000Z
- Updated: 2026-10-17T05:56:56.000Z
- Description: Someone added loading lazy to every image in the theme, including the one the browser needs first. Here is how the biggest LCP regression I have seen was…
- Author: Rohit Bhadani
- Tags: Shopify, Error Autopsy, Performance, Ecommerce

A store went from an LCP of 1.9 seconds to 4.3 seconds overnight with no new apps, no new sections and no traffic change. The only commit in between was described in the pull request as "add lazy loading to images for performance".

That change did improve the total bytes downloaded. It also made the single most important metric on the page substantially worse, and the reason is worth understanding properly because the same mistake is in an enormous number of themes.

## The quick version

`loading="lazy"` on the LCP element delays its request until after layout, which is the one image that must never be delayed. Lazy loading is correct for everything below the fold and actively harmful for the thing at the top of it.

Set `loading="eager"` and `fetchpriority="high"` on the first meaningful image in the viewport, and lazy load everything else.

Tested on Dawn 15, Chrome 133, Shopify CDN.

## Cause one: the attribute is applied unconditionally

The usual shape of the problem in a Liquid snippet:

```liquid
{%- comment -%} every image, everywhere, lazy {%- endcomment -%}
<img
  src="{{ image | image_url: width: 1500 }}"
  srcset="..."
  loading="lazy"
  width="{{ image.width }}"
  height="{{ image.height }}"
  alt="{{ image.alt | escape }}">

```

A lazy image is not requested until the browser has computed layout and decided it is near the viewport. That means it waits behind CSS, behind layout, and behind whatever else the network is doing. For an image that is already visible on load, you have added a dependency chain to the very thing the user is waiting for.

The fix is to make the behaviour conditional on position. In a Shopify section, the reliable signal is the section's index in the template, or an explicit setting:

```liquid
{%- liquid
  assign is_priority = false
  if section.index <= 2
    assign is_priority = true
  endif
-%}

<img
  src="{{ image | image_url: width: 1500 }}"
  srcset="{{ image | image_url: width: 375 }} 375w,
          {{ image | image_url: width: 750 }} 750w,
          {{ image | image_url: width: 1100 }} 1100w,
          {{ image | image_url: width: 1500 }} 1500w"
  sizes="100vw"
  width="{{ image.width }}"
  height="{{ image.height }}"
  loading="{% if is_priority %}eager{% else %}lazy{% endif %}"
  fetchpriority="{% if is_priority %}high{% else %}auto{% endif %}"
  alt="{{ image.alt | escape }}">

```

`section.index` is one based and reflects order in the JSON template, so the first two sections cover the announcement bar, header and hero in most themes. It is a heuristic rather than a guarantee, which is why I also expose a "priority image" checkbox in the section schema for the cases where a merchant reorders things.

## Cause two: fetchpriority is missing even when loading is eager

Removing `loading="lazy"` is only half of it. An eager image still competes with every other resource for bandwidth, and the browser's default priority for images discovered in the initial HTML is Low until layout confirms they are in the viewport.

`fetchpriority="high"` tells the browser to promote it immediately, before layout. On a slow connection this is routinely worth several hundred milliseconds, and it is one attribute.

The corollary matters too: use it on exactly **one** image. If five images are high priority, none of them are, and you have simply recreated the original contention with extra steps.

## Cause three: the LCP element is not the image you think it is

Before optimising anything, confirm what the browser actually chose. It is frequently not the hero image.

```js
// paste into the console on a hard reload
new PerformanceObserver((list) => {
  const e = list.getEntries().at(-1)
  console.log('LCP', Math.round(e.startTime), e.element, e.url || '(text node)')
}).observe({ type: 'largest-contentful-paint', buffered: true })

```

Common surprises:

**A text block wins.** If the hero image is a CSS background image, it is not eligible for LCP at all in most cases, so the largest heading becomes the LCP element. That changes the fix entirely: you now care about font loading rather than image priority.

**The announcement bar wins** on a short viewport where the hero is partially below the fold.

**A different product card wins** on collection pages, and it is not the first one, because the first one is smaller in the grid.

I have watched people spend a week optimising an image that was never the LCP element. Ten seconds of measurement first.

## Cause four: the preload is fighting the srcset

A well meant preload can make things worse if it does not match what the `img` tag will eventually request:

```liquid
{%- comment -%} wrong: preloads a fixed width the img will not use {%- endcomment -%}
<link rel="preload" as="image" href="{{ image | image_url: width: 1500 }}">

```

On a mobile viewport the `img` picks the 375w candidate from `srcset`, and you have now downloaded two images: the preloaded 1500px one that nothing uses, and the 375px one that renders. The preload actively delayed LCP by consuming bandwidth.

If you preload a responsive image, the preload must carry the same `imagesrcset` and `imagesizes`:

```liquid
<link rel="preload" as="image"
      imagesrcset="{{ image | image_url: width: 375 }} 375w,
                   {{ image | image_url: width: 750 }} 750w,
                   {{ image | image_url: width: 1500 }} 1500w"
      imagesizes="100vw"
      fetchpriority="high">

```

Honestly, with `fetchpriority="high"` on the image itself, the preload is usually unnecessary. It helps when the image is discovered late, for example inside a section rendered further down the HTML, and it hurts whenever the attributes drift out of sync. I default to no preload and one high priority image.

## Cause five: the image is behind a JavaScript slideshow

If your hero is a carousel that initialises in JavaScript, the first slide may not exist in the HTML at all. The browser cannot prioritise what it cannot see, and LCP waits for the bundle to parse, execute and inject the markup.

Render the first slide in Liquid, statically, with the correct priority attributes, and let JavaScript enhance it into a carousel afterwards. This is the single largest structural win available on most hero sections and it also removes a layout shift, which I go into in [the announcement bar that cost 0.31 CLS](https://debugly.dev/shopify-cls-announcement-bar-fonts/).

## Prevention

**Measure the LCP element before changing anything.** The observer snippet above, on a throttled reload. Everything else in this post is wasted effort if you optimise the wrong node.

**One eager, high priority image per page. Everything else lazy.** Write it as a rule in the theme's contributing notes, because the next person will add `loading="lazy"` to everything again.

**Never lazy load anything in the first two sections.** Make it conditional on `section.index` so it survives merchants reordering their homepage.

**Always set width and height.** Not for LCP, for CLS, and it costs nothing to do both at once.

**Test on a throttled mobile profile.** Shopify's own speed score uses 4x CPU throttling and a slow connection, and a hero image that is fine on your laptop can easily be the difference between a 1.9 and a 4.3 on the profile that is actually being scored. The [measure first, fix in order](https://debugly.dev/shopify-theme-lighthouse-performance-debugging/) method applies here exactly.

---

Published on [debugly.dev](https://debugly.dev/) by [Rohit Bhadani](https://debugly.dev/about/) | Founder of [Krova Cloud](https://krova.cloud/?ref=debugly.dev)