Shopify development · Theme performance

Why Your Shopify Theme Is Slow: Debugging Liquid, JavaScript, and Images

Find the slow stage, inspect the code behind it, and make measurable improvements that preserve the shopping experience.

A slow Shopify theme is rarely caused by one oversized file. Liquid can delay the content arriving, JavaScript can keep the browser busy, and images can compete for bandwidth or appear too late. Fixing the wrong layer wastes development time while customers continue waiting.

This guide shows how to separate those problems, inspect the evidence, and make targeted changes. The examples are for Shopify hosted Liquid themes, rather than Hydrogen storefronts. Work on a duplicate theme, keep a comparison baseline, and adapt each snippet to the actual component before publishing.

1. Establish a baseline that means something

Select representative homepage, collection, and product URLs. Include a product with many variants and a collection with realistic content. Test mobile first, then desktop, keeping device settings, network conditions, cache state, and customer journey consistent between comparisons.

Use field data to understand real visits and laboratory tools to reproduce specific problems. Google's good Core Web Vitals thresholds are LCP within 2.5 seconds, INP at most 200 milliseconds, and CLS at most 0.1, assessed at the seventy-fifth percentile.[1]

Match the complaint to the evidence
MetricCustomer experienceInspect first
LCPMain content appears lateDocument timing, image discovery, rendering
INPTaps feel unresponsiveInteraction traces and main thread work
CLSContent jumps unexpectedlyImage dimensions, fonts, injected elements

Record several runs rather than selecting the best score. Save the tested theme version, browser settings, and screenshots. A score without its testing conditions is difficult to reproduce and weak evidence for a production change.

Include first visits and repeat visits where they matter to the store. Record consent state, selected market, and whether the cart already contains products. These conditions can change the scripts and content loaded. Compare equivalent preview environments during development instead of attributing every preview difference to your edit.

2. Locate the delay before editing code

Inspect the document request, resource waterfall, and Performance recording. Identify the actual LCP element instead of assuming the banner is responsible. Separate late HTML arrival, late resource discovery, slow downloading, and delayed rendering after the resource arrives.

Shopify can stream the document before every section finishes rendering. Consequently, a fast time to first byte does not establish that all Liquid work finished quickly. Inspect document transfer timing and Liquid profiling together.[2]

Choose the next investigation from the trace
Where does the visible delay occur?
Content arrives late
Profile Liquid sections and repeated work
Browser stays busy
Inspect JavaScript, styles, and layout
Main image appears late
Inspect discovery, priority, size, and visibility

Change one suspected cause at a time. If an image downloads quickly but remains hidden behind an animation, compressing it further will not remove that rendering delay. Use the trace to select an intervention you can explain.

Write a testable hypothesis before changing the component. For example, “the hero request starts late because the image is inserted by JavaScript” suggests checking discovery time after rendering it in HTML. This connects the code change with a specific observation and makes an unsuccessful experiment useful.

3. Profile Liquid and remove unnecessary work

Shopify Theme Inspector exposes Liquid profiling through a flame graph. Use it to locate expensive sections and snippets, then examine why they run so often.[3] Large nested loops, repeated lookups, and deeply repeated snippet calls deserve investigation.

For example, a template that needs one variant's initial price should not scan every variant just to find it. The following comparison illustrates the difference. It is a focused rendering example, not a replacement for a complete product price component.

Before · scanning variants for one initial priceliquid
{% for variant in product.variants %}
  {% if variant.id == product.selected_or_first_available_variant.id %}
    <span class="product-price">{{ variant.price | money }}</span>
  {% endif %}
{% endfor %}

After · access the selected variant directlyliquid
{% assign chosen_variant = product.selected_or_first_available_variant %}

{% if chosen_variant != blank %}
  <span class="product-price">{{ chosen_variant.price | money }}</span>
{% endif %}

The selected variant property provides the relevant object directly.[4] Preserve existing variant-change behaviour, sale pricing, accessibility announcements, and currency formatting when applying the pattern. An initially correct price that stops updating is a functional regression.

Move repeated calculations outside a loop only when their inputs stay constant. Product-specific metafields still belong to their product's context. Measure the resulting render time instead of assuming every shorter template executes faster.

Test rendering changes against varied catalogue data, including missing images, unavailable variants, translated text, and products with different option structures. A shortcut that works on one demonstration product can fail elsewhere. Keep content rules explicit, and verify that performance gains do not come from accidentally omitting required information.

4. Keep collection rendering bounded

Render the products needed for the current page rather than building an oversized hidden catalogue in the DOM. Shopify's paginate tag provides a defined page size and pagination state.[5] This example keeps the page and loop bounded while retaining navigation.

Collection template · simplified bounded product listliquid
{% paginate collection.products by 24 %}
  <ul class="perf-product-grid">
    {% for card_product in collection.products limit: 24 %}
      <li>
        <a href="{{ card_product.url | escape }}">
          {{ card_product.title | escape }}
        </a>
      </li>
    {% endfor %}
  </ul>

  {% if paginate.pages > 1 %}
    <nav aria-label="Collection pagination">
      {{ paginate | default_pagination }}
    </nav>
  {% endif %}
{% endpaginate %}

Adapt the existing product card instead of replacing its required content with this simplified list. Test collection filters, sorting, pagination links, empty results, and translated labels. A faster collection that hides relevant products or breaks navigation fails the customer journey.

Inspect repeated card features as well. A small amount of unnecessary work can become significant when multiplied across many products and sections. Prefer a deliberate content limit over generating everything and hiding most of it with CSS.

If JavaScript enhances pagination, retain usable links and preserve filter and sorting parameters. Test browser navigation as well as clicking the next page. Replacing a complete collection with a partially updated section can leave stale counts or filters, creating confusion even when the next request finishes quickly.

5. Separate JavaScript downloads from execution

A large script and a slow script are not always the same thing. Use the Network panel for transferred resources and the Performance panel for execution, long tasks, style recalculation, and interaction delays. DevTools Coverage helps identify code unused during the recorded journey.[6]

This browser console snippet lists observed script resource timings. It is a starting inventory, not a CPU profile or an INP measurement.

Browser console · observed script download timingsjavascript
(() => {
  const scripts = performance.getEntriesByType('resource')
    .filter((entry) => entry.initiatorType === 'script')
    .sort((a, b) => b.duration - a.duration)
    .slice(0, 12)
    .map((entry) => ({
      url: entry.name,
      durationMs: Math.round(entry.duration),
      transferBytes: entry.transferSize || 'cached / unavailable'
    }));

  console.table(scripts);
})();

A zero transfer size can reflect caching or unavailable cross-origin timing details; it does not establish that the script is free.[7] Correlate the inventory with initiators and execution traces before deciding what to remove.

Record real interactions: opening navigation, changing a variant, filtering products, and updating the cart. Repeated listeners or repeated component initialisation may reveal themselves only after several interactions or section replacements.

Unused coverage describes the session you recorded, not every possible visitor journey. A cart routine might remain unused until a shopper adds something. Exercise the required features before deleting shared code, and consider splitting optional components when only a small part of a bundle serves the current page.

6. Load scripts deliberately and simplify interactions

Use defer for appropriate external classic scripts so HTML parsing can continue while files download. Deferred scripts preserve their document order. Async is suitable for independent scripts whose execution order does not matter.[8]

Load existing product assets once, in dependency orderliquid
{% if request.page_type == 'product' %}
  <script src="{{ 'vendor-library.js' | asset_url }}" defer></script>
  <script src="{{ 'product-enhancements.js' | asset_url }}" defer></script>
{% endif %}

Replace these filenames with existing theme assets and include each only once. Check dependencies carefully: an inline script can execute before a deferred dependency is ready. Deferring a large script changes scheduling, but its execution still consumes browser time.

Use native HTML where it supplies the required behaviour. A simple expandable description can use details and summary without downloading an accordion library. Keep its styling lightweight and preserve keyboard access.

Product template · native disclosure without a JS libraryliquid
{% if product.description != blank %}
  <details class="product-description">
    <summary>Product details</summary>
    <div class="rte">
      {{ product.description }}
    </div>
  </details>
{% endif %}

Reserve delayed loading for genuinely optional features. Moving essential product information, navigation, or cart behaviour behind arbitrary timers trades one problem for another. Test the first interaction after any loading change.

A normal Lighthouse page-load test does not measure a complete interaction journey. Its Total Blocking Time can provide a useful laboratory clue, but it is not interchangeable with field INP. Validate interaction changes with recorded taps and clicks, including activity after the page has been open for a while.

7. Give the actual LCP image the right treatment

Do not lazy-load the image that customers immediately need to see. Shopify recommends eager loading for the LCP image; an appropriate high fetch priority can further communicate its importance.[9] Applying high priority to every image defeats the distinction.

This example assumes a full-width hero and an existing section image_picker setting named image. Shopify's image filters generate CDN URLs and responsive image markup.[10] Keep useful alternative text on the selected image.

Full-width hero · existing image_picker setting: imageliquid
{% assign hero_image = section.settings.image %}

{% if hero_image != blank %}
  {{
    hero_image
    | image_url: width: 2400
    | image_tag:
      widths: '480, 750, 1100, 1500, 2000, 2400',
      sizes: '100vw',
      loading: 'eager',
      fetchpriority: 'high',
      class: 'perf-hero__image'
  }}
{% endif %}

Inspect the rendered HTML and request waterfall. Confirm that the image is discoverable promptly and not hidden by a slideshow, overlay, or reveal effect. Use the actual viewport's LCP candidate; desktop and mobile can identify different elements.

Do not add a preload simply because an image looks important. First establish whether the browser discovers it late and whether the hint targets the resource actually selected. Competing preloads can distract from essential downloads, while mismatched image hints can request an unnecessary alternative alongside the displayed image.

8. Size supporting images for their real layout

Use lazy loading for images genuinely outside the initial viewport. Supply candidate widths and a sizes attribute that describes the rendered slot, rather than treating every product thumbnail as full screen. Shopify documents responsive sizing through image_tag.[11]

Offscreen card only · requires a card_product objectliquid
{% assign card_image = card_product.featured_image %}

{% if card_image != blank %}
  {{
    card_image
    | image_url: width: 960
    | image_tag:
      widths: '240, 360, 480, 720, 960',
      sizes: '(min-width: 1200px) 280px, (min-width: 750px) calc((100vw - 64px) / 3), calc((100vw - 48px) / 2)',
      loading: 'lazy',
      class: 'perf-card__image'
  }}
{% endif %}

Example grid geometry matching the card sizes expressioncss
.perf-product-grid {
  box-sizing: border-box;
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 16px;
  width: 100%;
  max-width: 1200px;
  margin: 0 auto;
  padding: 16px;
  list-style: none;
}

.perf-product-grid > li {
  min-width: 0;
}

.perf-card__image,
.perf-hero__image {
  display: block;
  width: 100%;
  height: auto;
}

@media (min-width: 750px) {
  .perf-product-grid {
    grid-template-columns: repeat(3, minmax(0, 1fr));
  }
}

@media (min-width: 1200px) {
  .perf-product-grid {
    grid-template-columns: repeat(4, minmax(0, 1fr));
  }
}

The CSS and sizes expression agree on this example's grid: two columns on small screens, three on medium screens, and four within a capped desktop container. Adapt both together when changing gaps, padding, or column counts.

Choose image loading by its role
Measured LCP image
Eager loading and deliberate priority
Other visible images
Normal priority and appropriate dimensions
Offscreen images
Lazy loading and matching responsive sizes

Check selected resources in the console, then inspect transferred bytes in Network. Intrinsic dimensions and stable image proportions help the browser reserve space before downloads finish.

The lazy-loading card example is only for offscreen cards; do not apply it indiscriminately to the first visible row. Its sizes expression also assumes the grid can use the viewport width until its maximum. A sidebar or narrower parent changes that geometry and requires adjusted sizes values.

Browser console · compare selected images with visible slotsjavascript
(() => {
  const images = [...document.images]
    .filter((img) => img.getBoundingClientRect().width > 0)
    .slice(0, 20)
    .map((img) => ({
      source: img.currentSrc || img.src,
      renderedCssPixels: Math.round(img.getBoundingClientRect().width),
      intrinsicWidth: img.naturalWidth,
      loading: img.loading || 'eager (default)',
      priority: img.fetchPriority || 'auto'
    }));

  console.table(images);
})();

9. Audit apps, fonts, and shifting content

Inventory scripts by the feature that requires them. On a duplicate theme, disable one optional app embed or feature at a time and compare identical journeys. Review remaining injected assets and app documentation before removing code; uninstalling an app does not justify deleting every unfamiliar script.

Keep required consent, analytics, and commerce behaviour in the test plan. Removing a tracking tag can improve a synthetic score while silently breaking measurement. Document the feature owner and the observed performance cost before deciding whether the feature should remain.

Inspect layout shifts from review widgets, promotional bars, and late font changes. Reserve suitable space for predictable components and reduce unnecessary font families or weights. Where brand fonts remain, review fallback metrics to limit movement during swapping.[12]

Check for duplicate installations through theme code, app embeds, and tag management. Two copies of the same feature can add requests and duplicate event handling. Identify how each copy is installed before changing it, then verify that the retained implementation still serves every page where the business needs it.