Optimizing Site Speed with Lazy-Loading Images in Liquid

Table of Contents
Big thanks to our contributors those make our blogs possible.

Our growing community of contributors bring their unique insights from around the world to power our blog. 

Images are the single largest payload on most Shopify storefronts. Hero banners, product galleries, blog hero art, lookbooks, collection grids, lifestyle modules—together they often account for 60–80% of transferred bytes. That weight slows first render, drags Largest Contentful Paint (LCP), and inflates total blocking time if scripts wait on image layout. Fortunately, you can dramatically improve real-world load performance by lazy-loading non-critical images—deferring downloads until they are close to (or likely to enter) the viewport. Shopify themes are built in Liquid, giving you flexible control over when and how image markup is emitted. In this guide, you’ll learn performance fundamentals, when not to lazy-load, how to implement native lazy loading in Liquid templates, how to handle responsive images, placeholders, background images, accessibility, and measurement—so your store looks great without making shoppers wait.

1. Performance Context: Why Images Hurt & How Lazy-Loading Helps

1.1 Image Weight Dominates Page Payload

Even well-compressed JPEGs and WebPs for product photography can accumulate quickly across collection grids and long homepages. Each additional request increases network contention—especially painful on mobile.

1.2 Critical Rendering Path & LCP

Your largest above-the-fold image (hero, featured product, slideshow) is frequently the Largest Contentful Paint candidate. If you delay or lazy-load this image incorrectly, LCP worsens. Conversely, lazy-loading below-the-fold images frees bandwidth so the hero can arrive faster.

1.3 Data-Saving & Mobile UX

Users on limited data plans benefit when you load only what they might see. Scrolling triggers fetch; no scroll, no download.

Key takeaway: Lazy-load all non-critical imagery; eagerly load only what materially contributes to initial viewport rendering.

2. Lazy-Loading Approaches: Native vs Script-Based

2.1 Native Browser Lazy Loading

Modern browsers support the loading="lazy" attribute directly on <img> elements. When used, the browser defers fetching until the image is near the viewport (heuristics vary). This is the simplest, most broadly recommended approach.

liquidCopyEdit<img
  src="{{ image | image_url: width: 600 }}"
  alt="{{ image.alt | escape }}"
  loading="lazy">

2.2 Fetch Priority Tuning

For key images that must load fast (hero/LCP), use loading="eager" or omit the attribute (default eager) and optionally add fetchpriority="high" to hint importance in supporting browsers.

liquidCopyEdit<img
  src="{{ section.settings.hero_image | image_url: width: 1600 }}"
  alt="{{ section.settings.hero_image.alt | escape }}"
  loading="eager"
  fetchpriority="high">

2.3 JavaScript / IntersectionObserver Polyfills

Legacy browsers without native lazy loading can be supported by a lightweight script that swaps data-src into src when observed. This is useful for background images or complex art direction (multiple sources). Use sparingly—native support is broad.

3. When Not to Lazy-Load

Lazy-loading everything is a mistake. Do not lazy-load:

  • Primary hero/LCP image in the initial viewport.
  • Logo in header if it flashes in late (hurts visual stability).
  • Critical UI icons needed for layout.
  • Images essential for CLS avoidance when dimension boxes depend on them (instead, specify width/height CSS; see below).

Rule of thumb: If the user will see it without scrolling, load it eagerly

4. Preparing Image Data in Liquid

Shopify’s CDN can resize and format images on the fly via Liquid filters. Deliver right-sized assets to minimize transfer.

4.1 Width-Based Resizing

liquidCopyEdit{%- assign img = product.featured_image -%}
<img
  src="{{ img | image_url: width: 800 }}"
  alt="{{ img.alt | escape }}"
  width="{{ img.width }}"
  height="{{ img.height }}"
  loading="lazy">

The image_url filter (Online Store 2.0) transforms the source; Shopify serves a scaled version. Always provide intrinsic width & height attributes for CLS stability (Shopify auto-knows original dims; if you resize, compute ratio).

4.2 Responsive Srcset

Deliver multiple widths so the browser picks the smallest adequate one for the viewport density.

liquidCopyEdit{%- assign img = product.featured_image -%}
<img
  src="{{ img | image_url: width: 600 }}"
  srcset="
    {{ img | image_url: width: 300 }} 300w,
    {{ img | image_url: width: 600 }} 600w,
    {{ img | image_url: width: 900 }} 900w,
    {{ img | image_url: width: 1200 }} 1200w
  "
  sizes="(max-width: 600px) 100vw, 600px"
  alt="{{ img.alt | escape }}"
  loading="lazy">

Tip: Match the largest needed rendered width in your layout; don’t generate huge variants that never display.

4.3 Aspect Ratio Boxes (Prevent CLS)

Reserve layout space before image load:

liquidCopyEdit{%- assign ratio = img.aspect_ratio | default: 1.0 -%}
<div class="media-wrapper" style="position:relative;padding-top:{{ 100.0 | divided_by: ratio }}%;">
  <img
    src="{{ img | image_url: width: 600 }}"
    alt="{{ img.alt | escape }}"
    loading="lazy"
    style="position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;">
</div>

5. Conditional Lazy-Loading Logic in Liquid

You often need to mark only the first few images as eager.

5.1 Collection Grid Example

liquidCopyEdit<ul class="product-grid">
  {%- for product in collection.products -%}
    {%- assign featured = product.featured_image -%}
    <li class="product-grid__item">
      <a href="{{ product.url }}">
        <img
          src="{{ featured | image_url: width: 400 }}"
          alt="{{ featured.alt | escape }}"
          width="{{ featured.width }}"
          height="{{ featured.height }}"
          {% if forloop.index <= 4 %}
            loading="eager" fetchpriority="high"
          {% else %}
            loading="lazy"
          {% endif %}
        >
        <span class="product-grid__title">{{ product.title }}</span>
      </a>
    </li>
  {%- endfor -%}
</ul>

Here the first row (top fold) loads eagerly; subsequent rows lazy-load.

5.2 Section Setting Toggle

Let merchants decide:

liquidCopyEdit{% if section.settings.enable_lazy %}
  {% assign lazy_attr = 'lazy' %}
{% else %}
  {% assign lazy_attr = 'eager' %}
{% endif %}

<img
  src="{{ section.settings.image | image_url: width: 1600 }}"
  alt="{{ section.settings.image.alt | escape }}"
  loading="{{ lazy_attr }}">

6. Using image_tag Helper for Cleaner Templates

Shopify’s image_tag filter can emit responsive image markup and attributes automatically.

liquidCopyEdit{{ product.featured_image | image_url: width: 800 | image_tag:
    alt: product.title,
    loading: 'lazy',
    class: 'product-media',
    widths: '300, 600, 800, 1200',
    fetchpriority: 'low' }}
  • widths: comma list generates srcset.
  • Shopify sets sizes by default (theme dependent) or you can override.

7. Low-Quality Image Placeholders (LQIP) / Blur-Up

Improve perceived performance by showing an ultra-small blurred preview while the full image loads.

7.1 Generate a Tiny Placeholder URL

Shopify can generate small widths (e.g., 20px).

liquidCopyEdit{% assign placeholder = img | image_url: width: 20 %}
{% assign main_src = img | image_url: width: 800 %}
<div class="blur-up">
  <img
    src="{{ placeholder }}"
    data-src="{{ main_src }}"
    alt="{{ img.alt | escape }}"
    class="blur-up__img lazyload">
</div>

7.2 CSS Blur Transition

cssCopyEdit.blur-up__img {
  filter: blur(20px);
  transition: filter 400ms;
}
.blur-up__img.loaded {
  filter: blur(0);
}

7.3 JS Swap On Intersection

jsCopyEditdocument.addEventListener('DOMContentLoaded', function(){
  const opts = { rootMargin: '200px 0px' };
  const io = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (!entry.isIntersecting) return;
      const img = entry.target;
      img.src = img.dataset.src;
      img.addEventListener('load', () => img.classList.add('loaded'), { once: true });
      io.unobserve(img);
    });
  }, opts);
  document.querySelectorAll('img.lazyload').forEach(img => io.observe(img));
});

Use this only if you need the blur-up effect; otherwise rely on native loading="lazy".

8. Lazy-Loading Background Images

background-image CSS won’t lazy-load natively because there’s no loading attribute. Use data attributes + IntersectionObserver to swap classes.

8.1 Liquid Markup

liquidCopyEdit<div
  class="banner banner--lazy"
  data-bg="{{ section.settings.bg_image | image_url: width: 1920 }}"
  role="img"
  aria-label="{{ section.settings.bg_image.alt | escape }}">
  <div class="banner__content">
    {{ section.settings.heading }}
  </div>
</div>

8.2 JS Loader

jsCopyEditconst banners = document.querySelectorAll('.banner--lazy');
const io = new IntersectionObserver((entries, obs) => {
  entries.forEach(e => {
    if (!e.isIntersecting) return;
    const el = e.target;
    const url = el.dataset.bg;
    el.style.backgroundImage = `url("${url}")`;
    el.classList.add('banner--loaded');
    obs.unobserve(el);
  });
}, { rootMargin: '300px' });
banners.forEach(b => io.observe(b));

Provide a lightweight background color or gradient placeholder in CSS to avoid layout jank before load.

9. Accessibility Considerations

Lazy-loading must not break accessibility:

  • Always supply meaningful alt text for content images; empty alt="" for purely decorative.
  • If using placeholder & JS swap, ensure src always points to some valid resource so screen readers don’t fail; small transparent placeholder is fine.
  • Background images representing content need an aria-label or real <img> for semantics.
  • Avoid hiding images with display:none until load; invisible content may not be announced.

10. Testing & Measuring Performance Gains

10.1 Lab Tests

Run performance audits (e.g., Lighthouse) before and after implementing lazy loading. Record:

  • LCP
  • Total Byte Weight
  • Time to Interactive not directly image-related but may benefit from lower network pressure

10.2 Field / RUM Metrics

If you capture real user metrics (e.g., custom analytics events or performance API beacons), compare median LCP and average total bytes transferred per session post-deploy.

10.3 Synthetic Scroll Testing

Simulate user scroll to ensure below-fold images fetch correctly and quickly—especially on slower 3G network throttles.

10.4 Visual QA

Confirm that eager images appear instantly; lazy images fade in gracefully; no broken placeholders.

11. Troubleshooting Common Lazy-Loading Issues

SymptomLikely CauseFix
Images never loadJS Lazy loader not firing; element hidden; wrong selectorUse native loading="lazy" fallback; test IntersectionObserver.
Blurry images remainLoad event not adding loaded class; caching placeholderEnsure img.complete check; remove cache bust.
Layout shift on loadMissing width/height or aspect ratio boxAdd intrinsic dimensions or ratio container.
LCP worsenedHero incorrectly marked lazy; network competingEager-load hero; consider fetchpriority="high" and preload.
Double downloadsBoth placeholder src and data lazy src heavyUse extremely small placeholder or inline data URI.
Shopify CDN returning large imagesWrong width param; no srcsetConfirm image_url: width: values; set srcset list.

12. Performance Pattern Recipes

12.1 Lightweight Product Card

liquidCopyEdit{%- assign img = product.featured_image -%}
<figure class="card-media">
  <img
    src="{{ img | image_url: width: 320 }}"
    srcset="
      {{ img | image_url: width: 160 }} 160w,
      {{ img | image_url: width: 320 }} 320w,
      {{ img | image_url: width: 480 }} 480w
    "
    sizes="(max-width: 600px) 50vw, 160px"
    alt="{{ product.title | escape }}"
    loading="lazy"
  >
</figure>

Great for dense grids where images render small; don’t overserve 1000px+ widths.

Load first gallery image eagerly; lazy-load rest:

liquidCopyEdit<ul class="product-gallery">
  {%- for media in product.media -%}
    <li>
      <img
        src="{{ media | image_url: width: 1000 }}"
        alt="{{ media.alt | escape }}"
        {% if forloop.first %} loading="eager" fetchpriority="high" {% else %} loading="lazy" {% endif %}
      >
    </li>
  {%- endfor -%}
</ul>

12.3 Inline Critical SVG, Lazy Photos

Inline small vector icons directly (no request) and lazy-load all lifestyle photography below hero.

13. Implementation Checklist (Copy & Use)

Decide What’s Critical

  • Identify hero images & logo: eager load.
  • Identify above-fold product tiles: maybe eager first row.

Generate Scaled Assets

  • Use image_url to size appropriately per layout.
  • Provide srcset + sizes for responsive picks.

Add Lazy Attributes

  • loading="lazy" on all below-fold <img>.
  • Optional fetchpriority="low".

Preserve Layout Stability

  • Include width & height or aspect ratio container.
  • Set placeholder background color matching average image tone.

Enhance UX

  • Optional blur-up placeholder pattern.
  • Fade-in transition on load.

QA

  • Test mobile networks.
  • Verify LCP candidate not lazy.
  • Confirm alt text present.

Monitor

  • Track LCP & total bytes via analytics.
  • Watch error logs for failed image URLs.

14. Putting It All Together: Reusable Liquid Snippet

Create a snippets/lazy-image.liquid you can include across sections.

snippets/lazy-image.liquid

liquidCopyEdit{%- comment -%}
  lazy-image.liquid
  Usage:
    {% render 'lazy-image',
      image: my_image_object,
      max_width: 800,
      eager: false,
      class: 'my-img-class',
      sizes: '(max-width: 600px) 100vw, 600px' %}
{%- endcomment -%}

{%- assign img_obj = image -%}
{%- assign width = max_width | default: img_obj.width -%}
{%- assign eager_attr = eager | default: false -%}

{%- capture srcset_attr -%}
  {{ img_obj | image_url: width: width | append: ' ' | append: width | append: 'w,' }}
  {{ img_obj | image_url: width: width | times: 2 | append: ' ' | append: width | times: 2 | append: 'w' }}
{%- endcapture -%}

<img
  src="{{ img_obj | image_url: width: width }}"
  srcset="
    {{ img_obj | image_url: width: width | strip }} {{ width }}w,
    {{ img_obj | image_url: width: width | times: 2 | strip }} {{ width | times: 2 }}w
  "
  sizes="{{ sizes | default: '100vw' }}"
  alt="{{ img_obj.alt | escape }}"
  width="{{ img_obj.width }}"
  height="{{ img_obj.height }}"
  {% if eager_attr %}
    loading="eager" fetchpriority="high"
  {% else %}
    loading="lazy"
  {% endif %}
  class="{{ class }}">

Example usage in a section:

liquidCopyEdit{% render 'lazy-image',
   image: section.settings.hero_image,
   max_width: 1600,
   eager: true,
   class: 'hero-media',
   sizes: '(max-width: 768px) 100vw, 1600px' %}

15. Conclusion

Lazy-loading images is one of the highest-impact, lowest-effort performance wins you can ship in a Shopify Liquid theme—if you apply it selectively and thoughtfully. Load only what the visitor needs to see immediately; defer everything else. Pair lazy loading with properly sized responsive assets from Shopify’s CDN, dimension boxes to prevent layout shift, and crisp alt text for accessibility. Give merchants control via theme settings where it matters, but bake in sensible defaults (eager hero, lazy grids). Measure the results: you should see faster LCP, reduced data transfer, and improved engagement—especially on mobile shoppers where seconds (and kilobytes) count.

Start small: enable loading="lazy" on product grid thumbnails below the first row. Re-test performance. Then roll lazy loading across blog archives, carousels, and long form content. Within a few releases, your store will feel snappier, rank better on performance-sensitive metrics, and convert more visitors who aren’t stuck waiting on 5MB of unseen images.

Let's connect on TikTok

Join our newsletter to stay updated

Sydney Based Software Solutions Professional who is crafting exceptional systems and applications to solve a diverse range of problems for the past 10 years.

Share the Post

Related Posts