Shopify Image Optimization Bible
Complete guide to image optimization in Shopify. Master Liquid image filters, theme development, product images, and performance optimization for faster stores.
Shopify handles image optimization automatically through its CDN, but understanding how to leverage its features properly can significantly improve your store’s performance. This guide covers everything from basic Liquid filters to advanced optimization techniques.
Shopify Image Fundamentals
How Shopify Handles Images
Shopify automatically:
- Serves images from its global CDN
- Generates multiple sizes on-the-fly
- Converts to WebP for supporting browsers
- Caches optimized versions
Image URL Structure
https://cdn.shopify.com/s/files/1/0000/0001/files/
product-image.jpg
?v=1234567890 # Cache busting version
&width=400 # Requested width
&crop=center # Crop position
Available Parameters
| Parameter | Options | Description |
|---|---|---|
width | 1-5760 | Target width in pixels |
height | 1-5760 | Target height in pixels |
crop | top, center, bottom, left, right | Crop position |
format | jpg, png, pjpg, webp | Output format |
pad_color | hex color | Background for padding |
Liquid Image Filters
The image_url Filter
Modern Shopify’s primary image filter:
{{ product.featured_image | image_url: width: 800 }}
Output:
//cdn.shopify.com/s/files/1/0000/0001/files/product.jpg?v=123&width=800
Common Parameters
{%- comment -%} Basic resize {%- endcomment -%}
{{ image | image_url: width: 400 }}
{{ image | image_url: height: 300 }}
{%- comment -%} Fixed dimensions with crop {%- endcomment -%}
{{ image | image_url: width: 400, height: 400, crop: 'center' }}
{%- comment -%} Force format {%- endcomment -%}
{{ image | image_url: width: 800, format: 'pjpg' }}
The image_tag Filter
Generates complete img element:
{{ product.featured_image | image_url: width: 800 | image_tag }}
Output:
<img src="//cdn.shopify.com/.../product.jpg?width=800"
alt=""
width="800"
height="600">
Customizing image_tag
{{
product.featured_image |
image_url: width: 800 |
image_tag:
loading: 'lazy',
class: 'product-image',
alt: product.title
}}
Responsive Images
Basic Srcset
{%- assign widths = '165,360,535,750,1000,1500' | split: ',' -%}
<img
src="{{ image | image_url: width: 750 }}"
srcset="{%- for width in widths -%}
{{ image | image_url: width: width }} {{ width }}w{% unless forloop.last %}, {% endunless %}
{%- endfor -%}"
sizes="(min-width: 1200px) 50vw, 100vw"
alt="{{ image.alt | escape }}"
width="{{ image.width }}"
height="{{ image.height }}"
loading="lazy"
>
Responsive Image Snippet
Create snippets/responsive-image.liquid:
{%- comment -%}
Responsive image snippet
Usage: {% render 'responsive-image', image: product.featured_image, sizes: '50vw' %}
{%- endcomment -%}
{%- liquid
assign widths = '165,360,535,750,1000,1500,2000' | split: ','
assign default_width = 750
assign loading = loading | default: 'lazy'
assign fetchpriority = fetchpriority | default: nil
-%}
<img
src="{{ image | image_url: width: default_width }}"
srcset="{%- for width in widths -%}
{{ image | image_url: width: width }} {{ width }}w{% unless forloop.last %}, {% endunless %}
{%- endfor -%}"
sizes="{{ sizes | default: '100vw' }}"
alt="{{ image.alt | escape }}"
width="{{ image.width }}"
height="{{ image.height }}"
loading="{{ loading }}"
{%- if fetchpriority %}
fetchpriority="{{ fetchpriority }}"
{%- endif %}
{%- if class %}
class="{{ class }}"
{%- endif %}
>
Usage Examples
{%- comment -%} Hero image - eager load {%- endcomment -%}
{% render 'responsive-image',
image: section.settings.hero_image,
sizes: '100vw',
loading: 'eager',
fetchpriority: 'high'
%}
{%- comment -%} Product grid - lazy load {%- endcomment -%}
{% render 'responsive-image',
image: product.featured_image,
sizes: '(min-width: 1200px) 25vw, (min-width: 768px) 50vw, 100vw',
loading: 'lazy'
%}
Dawn Theme Patterns
Shopify’s Dawn theme uses modern image patterns.
Dawn’s Image Component
{%- comment -%} Based on Dawn's image rendering {%- endcomment -%}
{%- liquid
assign image_width = section.settings.image_width
assign aspect_ratio = section.settings.aspect_ratio
case aspect_ratio
when 'adapt'
assign aspect_ratio_value = image.aspect_ratio
when 'square'
assign aspect_ratio_value = 1
when 'portrait'
assign aspect_ratio_value = 0.75
when 'landscape'
assign aspect_ratio_value = 1.5
endcase
-%}
<div
class="media"
style="--aspect-ratio: {{ aspect_ratio_value }};"
>
{{ image | image_url: width: image_width | image_tag:
loading: 'lazy',
sizes: sizes,
widths: '165, 360, 535, 750, 1000, 1500',
class: 'motion-reduce'
}}
</div>
Dawn’s Lazy Loading Pattern
{%- if section.index == 1 -%}
{%- assign lazy_load = false -%}
{%- else -%}
{%- assign lazy_load = true -%}
{%- endif -%}
{{ image | image_url: width: 800 | image_tag:
loading: lazy_load | ternary: 'lazy', 'eager',
fetchpriority: lazy_load | ternary: nil, 'high'
}}
Product Images
Product Gallery
<div class="product-gallery">
{%- comment -%} Main image {%- endcomment -%}
<div class="product-main-image">
{% render 'responsive-image',
image: product.featured_image,
sizes: '(min-width: 1200px) 50vw, 100vw',
loading: 'eager',
fetchpriority: 'high',
class: 'product-featured'
%}
</div>
{%- comment -%} Thumbnails {%- endcomment -%}
<div class="product-thumbnails">
{%- for image in product.images -%}
<button
type="button"
class="thumbnail{% if forloop.first %} active{% endif %}"
data-image-url="{{ image | image_url: width: 1000 }}"
data-image-srcset="{%- for w in widths -%}{{ image | image_url: width: w }} {{ w }}w{% unless forloop.last %},{% endunless %}{%- endfor -%}"
>
<img
src="{{ image | image_url: width: 100 }}"
alt="{{ image.alt | escape | default: product.title }}"
width="100"
height="100"
loading="lazy"
>
</button>
{%- endfor -%}
</div>
</div>
Variant Images
{%- comment -%} Map variant images for JavaScript {%- endcomment -%}
<script type="application/json" id="variant-images">
{
{%- for variant in product.variants -%}
"{{ variant.id }}": {
"src": "{{ variant.featured_image | image_url: width: 1000 }}",
"srcset": "{%- for w in widths -%}{{ variant.featured_image | image_url: width: w }} {{ w }}w{% unless forloop.last %},{% endunless %}{%- endfor -%}",
"alt": {{ variant.featured_image.alt | default: variant.title | json }}
}{% unless forloop.last %},{% endunless %}
{%- endfor -%}
}
</script>
Zoom Support
<div
class="product-zoom-container"
data-zoom-image="{{ product.featured_image | image_url: width: 2000 }}"
>
<img
src="{{ product.featured_image | image_url: width: 800 }}"
srcset="{{ product.featured_image | image_url: width: 400 }} 400w,
{{ product.featured_image | image_url: width: 800 }} 800w,
{{ product.featured_image | image_url: width: 1200 }} 1200w"
sizes="(min-width: 1200px) 50vw, 100vw"
alt="{{ product.featured_image.alt | escape }}"
loading="eager"
>
</div>
Collection Pages
Product Grid
<div class="collection-grid">
{%- for product in collection.products -%}
<article class="product-card">
<a href="{{ product.url }}">
{%- if product.featured_image -%}
<img
src="{{ product.featured_image | image_url: width: 400 }}"
srcset="{{ product.featured_image | image_url: width: 200 }} 200w,
{{ product.featured_image | image_url: width: 300 }} 300w,
{{ product.featured_image | image_url: width: 400 }} 400w,
{{ product.featured_image | image_url: width: 600 }} 600w"
sizes="(min-width: 1200px) 20vw, (min-width: 768px) 33vw, 50vw"
alt="{{ product.featured_image.alt | escape | default: product.title }}"
width="{{ product.featured_image.width }}"
height="{{ product.featured_image.height }}"
loading="{% if forloop.index <= 4 %}eager{% else %}lazy{% endif %}"
>
{%- else -%}
{%- comment -%} Placeholder {%- endcomment -%}
{{ 'product-1' | placeholder_svg_tag: 'placeholder-svg' }}
{%- endif -%}
</a>
<h3>{{ product.title }}</h3>
<p>{{ product.price | money }}</p>
</article>
{%- endfor -%}
</div>
Hover Image Swap
<div class="product-card-images">
{%- if product.images.size > 1 -%}
<img
class="product-card-image primary"
src="{{ product.featured_image | image_url: width: 400 }}"
alt="{{ product.featured_image.alt | escape }}"
loading="lazy"
>
<img
class="product-card-image hover"
src="{{ product.images[1] | image_url: width: 400 }}"
alt="{{ product.images[1].alt | escape }}"
loading="lazy"
>
{%- else -%}
<img
class="product-card-image"
src="{{ product.featured_image | image_url: width: 400 }}"
alt="{{ product.featured_image.alt | escape }}"
loading="lazy"
>
{%- endif -%}
</div>
.product-card-images {
position: relative;
}
.product-card-image.hover {
position: absolute;
top: 0;
left: 0;
opacity: 0;
transition: opacity 0.3s ease;
}
.product-card:hover .product-card-image.hover {
opacity: 1;
}
Banner and Hero Images
Hero Section
{%- comment -%} sections/hero-banner.liquid {%- endcomment -%}
{%- liquid
assign image = section.settings.image
assign mobile_image = section.settings.mobile_image
-%}
<section class="hero-banner">
<picture>
{%- if mobile_image -%}
<source
media="(max-width: 749px)"
srcset="{{ mobile_image | image_url: width: 375 }} 375w,
{{ mobile_image | image_url: width: 550 }} 550w,
{{ mobile_image | image_url: width: 750 }} 750w"
sizes="100vw"
>
{%- endif -%}
<source
media="(min-width: 750px)"
srcset="{{ image | image_url: width: 1000 }} 1000w,
{{ image | image_url: width: 1500 }} 1500w,
{{ image | image_url: width: 2000 }} 2000w,
{{ image | image_url: width: 3000 }} 3000w"
sizes="100vw"
>
<img
src="{{ image | image_url: width: 1500 }}"
alt="{{ image.alt | escape }}"
width="{{ image.width }}"
height="{{ image.height }}"
loading="eager"
fetchpriority="high"
>
</picture>
<div class="hero-content">
<h1>{{ section.settings.heading }}</h1>
</div>
</section>
{% schema %}
{
"name": "Hero Banner",
"settings": [
{
"type": "image_picker",
"id": "image",
"label": "Desktop Image"
},
{
"type": "image_picker",
"id": "mobile_image",
"label": "Mobile Image (optional)"
},
{
"type": "text",
"id": "heading",
"label": "Heading"
}
]
}
{% endschema %}
Background Images via CSS
{%- style -%}
.hero-section {
background-image: url('{{ section.settings.image | image_url: width: 1500 }}');
}
@media (min-width: 1500px) {
.hero-section {
background-image: url('{{ section.settings.image | image_url: width: 2500 }}');
}
}
@media (max-width: 749px) {
.hero-section {
background-image: url('{{ section.settings.image | image_url: width: 750 }}');
}
}
{%- endstyle -%}
Theme Settings Images
Logo Optimization
{%- comment -%} In header.liquid {%- endcomment -%}
{%- if settings.logo -%}
{%- assign logo_height = settings.logo_height | default: 50 -%}
{%- assign logo_width = settings.logo.width | times: logo_height | divided_by: settings.logo.height -%}
<img
src="{{ settings.logo | image_url: height: logo_height }}"
srcset="{{ settings.logo | image_url: height: logo_height }} 1x,
{{ settings.logo | image_url: height: logo_height | times: 2 }} 2x"
alt="{{ settings.logo.alt | default: shop.name }}"
width="{{ logo_width }}"
height="{{ logo_height }}"
loading="eager"
>
{%- else -%}
<span class="header-logo-text">{{ shop.name }}</span>
{%- endif -%}
Favicon
{%- comment -%} In theme.liquid head {%- endcomment -%}
{%- if settings.favicon -%}
<link rel="icon" type="image/png" href="{{ settings.favicon | image_url: width: 32, height: 32 }}">
<link rel="apple-touch-icon" href="{{ settings.favicon | image_url: width: 180, height: 180 }}">
{%- endif -%}
Performance Optimization
Critical Image Preloading
{%- comment -%} In theme.liquid head {%- endcomment -%}
{%- if template.name == 'index' and section.settings.hero_image -%}
<link
rel="preload"
as="image"
href="{{ section.settings.hero_image | image_url: width: 1500 }}"
imagesrcset="{{ section.settings.hero_image | image_url: width: 750 }} 750w,
{{ section.settings.hero_image | image_url: width: 1500 }} 1500w,
{{ section.settings.hero_image | image_url: width: 2500 }} 2500w"
imagesizes="100vw"
>
{%- endif -%}
LCP Optimization
{%- comment -%} Detect if section is above the fold {%- endcomment -%}
{%- liquid
if section.index == 1
assign loading = 'eager'
assign fetchpriority = 'high'
else
assign loading = 'lazy'
assign fetchpriority = nil
endif
-%}
<img
src="{{ image | image_url: width: 800 }}"
loading="{{ loading }}"
{% if fetchpriority %}fetchpriority="{{ fetchpriority }}"{% endif %}
>
Lazy Loading Collection Pages
{%- comment -%} Eager load first row, lazy load rest {%- endcomment -%}
{%- assign products_per_row = section.settings.products_per_row | default: 4 -%}
{%- for product in collection.products -%}
{%- assign row = forloop.index | minus: 1 | divided_by: products_per_row -%}
<img
src="{{ product.featured_image | image_url: width: 400 }}"
loading="{% if row == 0 %}eager{% else %}lazy{% endif %}"
{% if row == 0 and forloop.index == 1 %}fetchpriority="high"{% endif %}
>
{%- endfor -%}
Image Placeholders
Blur-Up Placeholder
{%- comment -%} Generate tiny placeholder {%- endcomment -%}
{%- assign placeholder_url = image | image_url: width: 20 -%}
<div class="image-wrapper" data-loaded="false">
<img
class="placeholder-image"
src="{{ placeholder_url }}"
alt=""
aria-hidden="true"
>
<img
class="main-image"
src="{{ image | image_url: width: 800 }}"
alt="{{ image.alt | escape }}"
loading="lazy"
onload="this.parentElement.dataset.loaded = 'true'"
>
</div>
<style>
.image-wrapper {
position: relative;
overflow: hidden;
}
.placeholder-image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
filter: blur(10px);
transform: scale(1.1);
transition: opacity 0.3s;
}
.image-wrapper[data-loaded="true"] .placeholder-image {
opacity: 0;
}
.main-image {
display: block;
width: 100%;
}
</style>
Skeleton Placeholder
<div class="image-skeleton">
<img
src="{{ image | image_url: width: 800 }}"
alt="{{ image.alt | escape }}"
loading="lazy"
onload="this.parentElement.classList.add('loaded')"
>
</div>
<style>
.image-skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.image-skeleton.loaded {
background: none;
animation: none;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>
Metafield Images
Accessing Metafield Images
{%- comment -%} File reference metafield {%- endcomment -%}
{%- assign custom_image = product.metafields.custom.lifestyle_image.value -%}
{%- if custom_image -%}
<img
src="{{ custom_image | image_url: width: 800 }}"
alt="{{ custom_image.alt | escape }}"
loading="lazy"
>
{%- endif -%}
List of Images Metafield
{%- assign gallery_images = product.metafields.custom.gallery.value -%}
{%- if gallery_images.size > 0 -%}
<div class="custom-gallery">
{%- for image in gallery_images -%}
<img
src="{{ image | image_url: width: 400 }}"
alt="{{ image.alt | escape }}"
loading="lazy"
>
{%- endfor -%}
</div>
{%- endif -%}
WebP and Format Optimization
Shopify automatically serves WebP when supported, but you can force formats:
Force Format
{%- comment -%} Force WebP {%- endcomment -%}
{{ image | image_url: width: 800, format: 'webp' }}
{%- comment -%} Force progressive JPEG {%- endcomment -%}
{{ image | image_url: width: 800, format: 'pjpg' }}
{%- comment -%} Force PNG (for transparency) {%- endcomment -%}
{{ image | image_url: width: 800, format: 'png' }}
Picture Element for Format Control
<picture>
<source
type="image/webp"
srcset="{{ image | image_url: width: 400, format: 'webp' }} 400w,
{{ image | image_url: width: 800, format: 'webp' }} 800w"
>
<img
src="{{ image | image_url: width: 800 }}"
srcset="{{ image | image_url: width: 400 }} 400w,
{{ image | image_url: width: 800 }} 800w"
alt="{{ image.alt | escape }}"
loading="lazy"
>
</picture>
Third-Party CDN Integration
Using Sirv with Shopify
{%- comment -%} Replace Shopify CDN with Sirv {%- endcomment -%}
{%- assign sirv_base = 'https://yourstore.sirv.com' -%}
{%- assign image_path = product.featured_image.src | split: 'files/' | last -%}
<img
src="{{ sirv_base }}/{{ image_path }}?w=800&format=optimal"
srcset="{{ sirv_base }}/{{ image_path }}?w=400&format=optimal 400w,
{{ sirv_base }}/{{ image_path }}?w=800&format=optimal 800w,
{{ sirv_base }}/{{ image_path }}?w=1200&format=optimal 1200w"
alt="{{ product.featured_image.alt | escape }}"
loading="lazy"
>
Cloudinary Integration
{%- assign cloudinary_base = 'https://res.cloudinary.com/yourcloud/image/fetch' -%}
{%- assign original_url = product.featured_image | image_url: width: 2000 | prepend: 'https:' -%}
<img
src="{{ cloudinary_base }}/w_800,f_auto,q_auto/{{ original_url }}"
alt="{{ product.featured_image.alt | escape }}"
loading="lazy"
>
Image Upload Optimization
Recommended Upload Specs
| Image Type | Dimensions | Format | Notes |
|---|---|---|---|
| Product | 2048×2048 | JPEG/PNG | Square, white background |
| Collection | 2000×1000 | JPEG | 2:1 aspect ratio |
| Hero | 2880×1000 | JPEG | Wide format |
| Logo | 400×200 | PNG/SVG | Transparent background |
Pre-Upload Optimization
Before uploading to Shopify:
# Optimize product images
mogrify -resize 2048x2048 -quality 85 -strip *.jpg
# Convert to sRGB
mogrify -colorspace sRGB -profile sRGB.icc *.jpg
App Integration
Using Apps for Optimization
Popular optimization apps:
- Crush.pics: Automatic compression
- TinyIMG: Compression + SEO
- Avada SEO: Image alt text automation
Custom App Integration
{%- comment -%} Check for optimized version from app {%- endcomment -%}
{%- if product.metafields.optimization_app.optimized_image -%}
{{ product.metafields.optimization_app.optimized_image | image_url: width: 800 | image_tag }}
{%- else -%}
{{ product.featured_image | image_url: width: 800 | image_tag }}
{%- endif -%}
Debugging and Testing
Check Image Sizes
{%- comment -%} Debug output {%- endcomment -%}
{% if request.design_mode %}
<div class="debug-info">
<p>Image: {{ image.src }}</p>
<p>Original: {{ image.width }}×{{ image.height }}</p>
<p>Aspect Ratio: {{ image.aspect_ratio }}</p>
</div>
{% endif %}
Performance Testing
Use these tools:
- Chrome DevTools Network tab
- Lighthouse audits
- WebPageTest with Shopify stores
- Shopify’s built-in speed report
Summary
Quick Reference
| Task | Liquid Code |
|---|---|
| Basic resize | {{ image | image_url: width: 800 }} |
| With crop | {{ image | image_url: width: 400, height: 400, crop: 'center' }} |
| Full img tag | {{ image | image_url: width: 800 | image_tag }} |
| Lazy loading | {{ image | image_url: width: 800 | image_tag: loading: 'lazy' }} |
| Alt text | {{ image | image_url: width: 800 | image_tag: alt: product.title }} |
Optimization Checklist
- ✅ Use
image_urlfilter for all images - ✅ Implement responsive srcset for product/collection images
- ✅ Eager load above-fold images (section.index == 1)
- ✅ Lazy load below-fold images
- ✅ Add fetchpriority=“high” to LCP images
- ✅ Include width/height attributes for CLS prevention
- ✅ Use appropriate sizes attribute
- ✅ Preload hero images in template head
- ✅ Upload high-quality source images (2048px+)
- ✅ Optimize images before upload (strip metadata)
- ✅ Use meaningful alt text for SEO
- ✅ Test with Lighthouse and speed reports
- ✅ Consider art direction with picture element
- ✅ Implement placeholder loading states
Shopify’s built-in image optimization handles the heavy lifting, but proper implementation of Liquid filters and loading strategies ensures your store achieves optimal performance.