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.

šŸ“Œ Chapter Overview

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's Performance Dashboard

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

Optimized Responsive Image Pattern
{%- 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:

Preloading LCP Image in layout/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 Handles Image Format Optimization

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:

CSS Loading Strategy
{%- 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:

Critical CSS Approach
<!-- 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. Use stylesheet_tag instead.
  • 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__title is faster than div.grid > .product-card > .card-inner > h3.title.
  • Use will-change sparingly — Only on elements that actually animate. Overuse wastes GPU memory.
  • Prefer transform and opacity for 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

JavaScript Loading Patterns
<!-- 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

Intersection Observer for Lazy Initialization
/**
 * 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);
Event Delegation (Fewer Event Listeners)
/**
 * 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

Liquid Performance Patterns
{%- 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
Preventing Image CLS with aspect-ratio
/* 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;
}
Font Loading Best Practice
<!-- 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

Optimized Font Loading
<!-- 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)

System Font Stack — No Download Required
: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.
šŸ’” Measuring Your Theme vs. 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

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

1

Open Chrome DevTools

Navigate to your store. Press F12 or Ctrl+Shift+I.

2

Go to Lighthouse Tab

Select "Performance" category. Choose "Mobile" device. Click "Analyze page load".

3

Analyze Results

Focus on LCP, INP (or TBT as proxy), and CLS scores. Read the recommendations.

4

Fix and Re-test

Address the highest-impact issues first. Re-run Lighthouse after each fix to measure improvement.

āš ļø Lighthouse Variability

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 defer on JavaScript files
  • Use Intersection Observer for lazy initialization of interactive components
  • Prevent CLS with image dimensions, font-display: swap, and reserved container heights
  • Use render over include for 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