Performance Optimization
Speed sells. A 1-second delay in page load reduces conversions by 7%. This chapter covers everything you need to know about optimizing Shopify themes for Core Web Vitals, from image optimization to JavaScript management and beyond.
Time estimate: 60 ā 75 minutes
Tags:
HTML
CSS
JavaScript
Liquid
Core Web Vitals Explained
Google uses Core Web Vitals as ranking signals. They also directly affect user experience and conversion rates. Here are the three metrics you must optimize:
LCP ā Largest Contentful Paint
How fast the largest visible element (usually a hero image) renders. Target: under 2.5 seconds.
INP ā Interaction to Next Paint
How quickly the page responds to user interactions (clicks, taps, keyboard). Target: under 200 milliseconds.
CLS ā Cumulative Layout Shift
How much the page layout shifts during loading (elements jumping around). Target: under 0.1.
Shopify provides a built-in speed score in the admin under
Online Store ā Themes ā Speed report. This score compares your
theme to similar stores. Aim for a score above 50 ā anything above 70 is excellent.
Remember: Shopify's content_for_header injects scripts you can't control,
which affects the score. Focus on what you can optimize.
Image Optimization (Biggest Impact)
Images account for 50ā80% of page weight on most eCommerce sites. This is your highest-impact optimization area.
Responsive Images with srcset
{%- comment -%}
PERFORMANCE-OPTIMIZED IMAGE PATTERN
Key principles:
1. Use srcset for responsive sizing
2. Use sizes to tell browser expected display width
3. Always set width and height (prevents CLS)
4. Lazy-load below-fold images
5. Eager-load above-fold hero/LCP images
{%- endcomment -%}
{%- assign is_above_fold = false -%}
{%- if section.index == 1 -%}
{%- assign is_above_fold = true -%}
{%- endif -%}
<img
src="{{ image | image_url: width: 800 }}"
srcset="
{{ image | image_url: width: 200 }} 200w,
{{ image | image_url: width: 400 }} 400w,
{{ image | image_url: width: 600 }} 600w,
{{ image | image_url: width: 800 }} 800w,
{{ image | image_url: width: 1000 }} 1000w,
{{ image | image_url: width: 1400 }} 1400w
"
sizes="(max-width: 749px) calc(100vw - 40px),
(max-width: 999px) calc(50vw - 40px),
400px"
alt="{{ image.alt | escape }}"
width="{{ image.width }}"
height="{{ image.height }}"
{% if is_above_fold %}
loading="eager"
fetchpriority="high"
{% else %}
loading="lazy"
decoding="async"
{% endif %}
>
Image Optimization Rules
| Rule | Why | How |
|---|---|---|
Always use srcset |
Browser picks the right size for the device | Provide 4ā6 width options from 200px to 1400px |
Always use sizes |
Tells the browser expected display width before image loads | Match your CSS layout breakpoints |
Always set width and height |
Prevents CLS by reserving space before image loads | width="{{ image.width }}" height="{{ image.height }}" |
| Lazy-load below-fold images | Reduces initial page weight and load time | loading="lazy" on everything below the fold |
| Eager-load hero/LCP image | Ensures the largest element loads fast (LCP) | loading="eager" fetchpriority="high" on hero image |
Use decoding="async" |
Prevents image decoding from blocking rendering | Add to all lazy-loaded images |
| Don't load what's hidden | Hidden images still download and waste bandwidth | Use Liquid conditionals to prevent rendering hidden images |
Preloading the LCP Image
For the hero banner (usually the LCP element), preload the image in the
<head> of theme.liquid:
<!-- In the <head> section of theme.liquid -->
{%- if template.name == 'index' -%}
{%- assign hero_image = sections['hero'].settings.image -%}
{%- if hero_image -%}
<link
rel="preload"
as="image"
href="{{ hero_image | image_url: width: 1400 }}"
imagesrcset="
{{ hero_image | image_url: width: 600 }} 600w,
{{ hero_image | image_url: width: 1000 }} 1000w,
{{ hero_image | image_url: width: 1400 }} 1400w
"
imagesizes="100vw"
>
{%- endif -%}
{%- endif -%}
Shopify's CDN automatically serves images in modern formats (WebP, AVIF) when
the browser supports them. You don't need to handle format conversion ā just
focus on serving the right size via srcset and sizes.
CSS Optimization
Split and Conditionally Load CSS
Don't load all your CSS on every page. Split into logical files and load only what's needed:
{%- comment -%}
BASE CSS: loads on every page (keep this small!)
Contains: reset, typography, utilities, layout grid, buttons
{%- endcomment -%}
{{ 'base.css' | asset_url | stylesheet_tag }}
{%- comment -%}
COMPONENT CSS: loaded only in sections that use them
Each section's .liquid file loads its own CSS at the top
{%- endcomment -%}
{%- comment -%} In sections/featured-collection.liquid: {%- endcomment -%}
{{ 'component-card.css' | asset_url | stylesheet_tag }}
{{ 'section-featured-collection.css' | asset_url | stylesheet_tag }}
{%- comment -%} In sections/slideshow.liquid: {%- endcomment -%}
{{ 'section-slideshow.css' | asset_url | stylesheet_tag }}
Critical CSS Inline Strategy
For above-the-fold content, consider inlining critical CSS to eliminate render-blocking requests:
<!-- In layout/theme.liquid <head> -->
<!-- Critical CSS inlined for instant first render -->
<style>
/* Only the minimum CSS needed for above-the-fold content */
*, *::before, *::after { box-sizing: border-box; margin: 0; }
body { font-family: var(--font-body-family); color: var(--color-text); }
.page-width { max-width: 1200px; margin: 0 auto; padding: 0 1.5rem; }
img { max-width: 100%; height: auto; display: block; }
.visually-hidden { position: absolute; clip: rect(0,0,0,0); }
/* Header critical styles */
.header { position: sticky; top: 0; z-index: 100; }
</style>
<!-- Non-critical CSS loaded asynchronously -->
<link
rel="preload"
href="{{ 'base.css' | asset_url }}"
as="style"
onload="this.onload=null;this.rel='stylesheet'"
>
<noscript>{{ 'base.css' | asset_url | stylesheet_tag }}</noscript>
CSS Performance Best Practices
- Avoid
@importā It creates additional blocking requests. Usestylesheet_taginstead. - Minimize use of
!importantā It makes CSS harder to optimize and override. - Use CSS custom properties ā They're more performant than Sass variables at runtime.
- Avoid complex selectors ā
.product-card__titleis faster thandiv.grid > .product-card > .card-inner > h3.title. - Use
will-changesparingly ā Only on elements that actually animate. Overuse wastes GPU memory. - Prefer
transformandopacityfor animations ā They don't trigger layout recalculations.
JavaScript Optimization
JavaScript is the most expensive resource type. It must be downloaded, parsed, compiled, and executed ā each step blocks rendering or interaction.
Loading Strategies
<!-- DEFER: Download in parallel, execute after HTML parsing -->
<!-- Use for: All JavaScript files (recommended default) -->
<script src="{{ 'global.js' | asset_url }}" defer></script>
<!-- ASYNC: Download in parallel, execute immediately when ready -->
<!-- Use for: Independent scripts like analytics -->
<script src="{{ 'analytics.js' | asset_url }}" async></script>
<!-- CONDITIONAL: Only load JS when the section is present -->
{%- if section.settings.enable_slideshow -%}
<script src="{{ 'slideshow.js' | asset_url }}" defer></script>
{%- endif -%}
<!-- NEVER do this ā blocks rendering! -->
<!-- <script src="..."></script> without defer or async -->
Performance-Friendly JS Patterns
/**
* Only initialize interactive features when they
* scroll into view ā not on page load.
*/
class LazySection extends HTMLElement {
constructor() {
super();
this.initialized = false;
}
connectedCallback() {
// Use Intersection Observer to delay initialization
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !this.initialized) {
this.init();
this.initialized = true;
observer.unobserve(this);
}
});
},
{ rootMargin: '200px' } // Start 200px before visible
);
observer.observe(this);
}
init() {
// Heavy initialization code goes here
// Only runs when element is near the viewport
console.log('Section initialized on scroll!');
}
}
customElements.define('lazy-section', LazySection);
/**
* INSTEAD OF: adding event listener to every button
*
* document.querySelectorAll('.add-to-cart').forEach(btn => {
* btn.addEventListener('click', handleClick);
* });
*
* USE: event delegation ā one listener on the parent
*/
document.addEventListener('click', function(event) {
const addToCartBtn = event.target.closest('[data-add-to-cart]');
if (!addToCartBtn) return;
event.preventDefault();
const variantId = addToCartBtn.dataset.variantId;
addToCart(variantId);
});
/**
* This pattern:
* 1. Uses fewer event listeners (better memory)
* 2. Works with dynamically added elements
* 3. Is the pattern Dawn uses throughout
*/
JavaScript Performance Rules
| Rule | Why |
|---|---|
Always use defer |
Prevents JS from blocking HTML parsing |
| Load JS conditionally | Don't load slideshow JS on pages without slideshows |
| Use event delegation | One listener vs. hundreds ā less memory, works with dynamic content |
| Use Intersection Observer | Defer initialization until elements are near viewport |
| Avoid jQuery | 30KB+ of overhead. Use vanilla JS ā it's more than capable |
| Debounce scroll/resize handlers | Prevents performance-killing rapid function calls |
Use requestAnimationFrame for visual updates |
Syncs with browser's render cycle for smooth animations |
| Prefer CSS transitions over JS animations | CSS animations are GPU-accelerated and more performant |
Liquid Performance
Liquid executes on Shopify's servers before HTML is sent to the browser. Poorly written Liquid increases server response time (Time to First Byte ā TTFB).
Liquid Performance Rules
{%- comment -%} ā BAD: Calling the same filter/property repeatedly {%- endcomment -%}
{% for product in collection.products %}
{{ product.featured_image | image_url: width: 400 }}
{{ product.featured_image | image_url: width: 400 }}
{{ product.featured_image | image_url: width: 400 }}
{% endfor %}
{%- comment -%} ā
GOOD: Assign once, reuse {%- endcomment -%}
{% for product in collection.products %}
{%- assign img_url = product.featured_image | image_url: width: 400 -%}
{{ img_url }}
{{ img_url }}
{{ img_url }}
{% endfor %}
{%- comment -%} ā BAD: Nested loops (O(n²) complexity) {%- endcomment -%}
{% for product in collection.products %}
{% for tag in product.tags %}
{% if tag == 'featured' %}...{% endif %}
{% endfor %}
{% endfor %}
{%- comment -%} ā
GOOD: Use contains instead of inner loop {%- endcomment -%}
{% for product in collection.products %}
{% if product.tags contains 'featured' %}...{% endif %}
{% endfor %}
{%- comment -%} ā BAD: Loading unused data {%- endcomment -%}
{% for product in collections.all.products %}
{{ product.title }}
{% endfor %}
{%- comment -%} ā
GOOD: Limit products loaded {%- endcomment -%}
{% for product in collections.all.products limit: 8 %}
{{ product.title }}
{% endfor %}
{%- comment -%} ā
GOOD: Use render instead of include (cacheable) {%- endcomment -%}
{% render 'product-card', product: product %}
Preventing Layout Shift (CLS)
Layout shift occurs when elements change position after the initial render. It's the most frustrating performance issue for users. Here's how to prevent it:
| Cause | Fix |
|---|---|
| Images without dimensions | Always set width and height attributes or use aspect-ratio in CSS |
| Web fonts loading late | Use font-display: swap and preconnect to font CDN |
| Dynamic content injected above viewport | Reserve space with min-height on containers; load content below the fold |
| Ads or embeds without reserved space | Set explicit container dimensions before content loads |
| Late-loading header/announcement bar | Render header in initial HTML, not via JavaScript |
/* Reserve space for images before they load */
.product-card__media {
position: relative;
overflow: hidden;
background-color: #f5f5f5; /* Placeholder color */
}
/* Square images */
.product-card__media--square {
aspect-ratio: 1 / 1;
}
/* Portrait images */
.product-card__media--portrait {
aspect-ratio: 3 / 4;
}
/* Adaptive ā uses actual image dimensions */
.product-card__media--adapt {
/* Width and height attributes on the img handle this */
}
.product-card__image {
width: 100%;
height: 100%;
object-fit: cover;
}
<!-- In layout/theme.liquid <head> -->
<!-- Preconnect to font CDN -->
<link rel="preconnect" href="https://fonts.shopifycdn.com" crossorigin>
<!-- Font display swap prevents invisible text -->
{%- style -%}
{{ settings.font_heading | font_face: font_display: 'swap' }}
{{ settings.font_body | font_face: font_display: 'swap' }}
{%- endstyle -%}
Font Optimization
Font Loading Strategy
<!-- Preload critical font files -->
{%- if settings.font_heading.system? == false -%}
<link
rel="preload"
as="font"
href="{{ settings.font_heading | font_url }}"
type="font/woff2"
crossorigin
>
{%- endif -%}
{%- comment -%}
Font optimization rules:
1. Use system fonts when possible (zero download cost)
2. Limit to 2 font families maximum
3. Limit font weights (400 and 700 cover most needs)
4. Always use font-display: swap
5. Preload the most critical font file
{%- endcomment -%}
System Font Stack (Zero-Cost Option)
:root {
--font-system: -apple-system, BlinkMacSystemFont,
'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell,
'Helvetica Neue', sans-serif;
/* Use as fallback or primary font */
--font-body-family: var(--font-system);
}
Managing Third-Party Impact
Shopify apps inject scripts through content_for_header. You can't
control these, but you can manage the impact:
- Audit installed apps ā Each app adds JavaScript. Recommend clients remove unused apps.
- Use app blocks instead of ScriptTag API ā App blocks load only on relevant pages.
- Defer non-critical third-party scripts ā Chat widgets, review popups, etc.
- Monitor with Lighthouse ā Run Lighthouse regularly to catch performance regressions from new apps.
To isolate your theme's performance from app impact, test with a development store with no apps installed. This gives you a clean baseline. Then test with apps to see their impact. Share both scores with clients to set realistic expectations.
Performance Audit Checklist
SHOPIFY THEME PERFORMANCE AUDIT
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
IMAGES
ā” All images use srcset with multiple widths
ā” All images have width and height attributes
ā” Below-fold images use loading="lazy"
ā” Hero/LCP image uses loading="eager" fetchpriority="high"
ā” LCP image is preloaded in <head>
ā” No oversized images (serving 2000px image in 400px container)
ā” Placeholder SVGs used for missing images
CSS
ā” CSS split into multiple files (base, component, section)
ā” Section CSS loaded conditionally at top of section files
ā” No @import statements in CSS
ā” Critical CSS considered for above-fold rendering
ā” No unused CSS loaded on pages that don't need it
JAVASCRIPT
ā” All scripts use defer or async
ā” JS loaded conditionally when section is present
ā” Event delegation used instead of per-element listeners
ā” No jQuery dependency
ā” Intersection Observer used for lazy initialization
ā” Scroll/resize handlers are debounced
LIQUID
ā” Expensive operations assigned to variables and reused
ā” No nested loops where avoidable
ā” render used instead of include
ā” Products limited with limit: parameter
ā” Whitespace controlled with {%- -%} tags
FONTS
ā” Maximum 2 font families loaded
ā” font-display: swap used
ā” Critical font preloaded
ā” System font stack used as fallback
LAYOUT SHIFT (CLS)
ā” All images have dimensions or aspect-ratio
ā” Fonts use font-display: swap
ā” No content injected above viewport after load
ā” Containers have min-height for dynamic content
TOOLS TO USE
ā” Chrome Lighthouse (DevTools ā Lighthouse tab)
ā” PageSpeed Insights (web.dev/measure)
ā” Shopify Speed Report (Admin ā Themes)
ā” WebPageTest.org (detailed waterfall analysis)
ā” Chrome DevTools Performance tab (runtime analysis)
Measuring Performance
Running Lighthouse
Open Chrome DevTools
Navigate to your store. Press F12 or Ctrl+Shift+I.
Go to Lighthouse Tab
Select "Performance" category. Choose "Mobile" device. Click "Analyze page load".
Analyze Results
Focus on LCP, INP (or TBT as proxy), and CLS scores. Read the recommendations.
Fix and Re-test
Address the highest-impact issues first. Re-run Lighthouse after each fix to measure improvement.
Lighthouse scores vary between runs due to network conditions and CPU load. Run it at least 3 times and average the results. For the most consistent results, use an Incognito window with extensions disabled, and close other tabs. PageSpeed Insights provides more stable results as it runs on Google's servers.
Key Takeaways
- Core Web Vitals (LCP, INP, CLS) directly affect SEO rankings and conversion rates
- Image optimization is the highest-impact change ā always use srcset, sizes, width, height
- Lazy-load below-fold images; eager-load and preload the hero/LCP image
- Split CSS into small files and load them conditionally per section
- Always use
deferon JavaScript files - Use Intersection Observer for lazy initialization of interactive components
- Prevent CLS with image dimensions, font-display: swap, and reserved container heights
- Use
renderoverincludefor cacheable Liquid snippets - Limit font families to 2 maximum; preload the primary font
- Audit third-party app impact separately from your theme's performance
- Run Lighthouse regularly ā performance is an ongoing discipline, not a one-time fix