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 generatessrcset
.- 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; emptyalt=""
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
Symptom | Likely Cause | Fix |
---|---|---|
Images never load | JS Lazy loader not firing; element hidden; wrong selector | Use native loading="lazy" fallback; test IntersectionObserver. |
Blurry images remain | Load event not adding loaded class; caching placeholder | Ensure img.complete check; remove cache bust. |
Layout shift on load | Missing width/height or aspect ratio box | Add intrinsic dimensions or ratio container. |
LCP worsened | Hero incorrectly marked lazy; network competing | Eager-load hero; consider fetchpriority="high" and preload . |
Double downloads | Both placeholder src and data lazy src heavy | Use extremely small placeholder or inline data URI. |
Shopify CDN returning large images | Wrong width param; no srcset | Confirm 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.
12.2 Gallery With Progressive Loading
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.