Shopify CLS/LCP Triage for App-Heavy Themes: Liquid and Script Surgery

By Milan Dhameliya · · 5 min read

Fix Shopify CLS and LCP regressions caused by Liquid over-fetching and app scripts with template-scoped loading, media guards, and budgets.

Your LCP is red because the largest element is waiting behind non-critical scripts and oversized Liquid payloads. Your CLS is red because image and widget containers do not reserve space before hydration.

Where this breaks on scaling stores

  1. Collection and product templates fetch far more objects than they render.
  2. App embeds inject CSS/JS globally even when only one template needs them.
  3. Hero images and recommendation cards do not lock dimensions at render time.
  4. Marketing scripts execute in the first critical second and block main-thread availability.

Validate the problem before editing code

Use three data views:

  1. CrUX or PageSpeed field data for mobile LCP/CLS percentile.
  2. Lighthouse trace for render-blocking resources and long tasks.
  3. Theme-level template audit for Liquid loops and app embed scope.

Run this in your theme repo to identify script sources fast:

rg -n "script|defer|async|googletagmanager|gtag|clarity|hotjar|klaviyo" layout sections snippets templates

Audit legacy script tag injection via Admin GraphQL:

query ScriptTagAudit {
  scriptTags(first: 50) {
    nodes {
      id
      src
      displayScope
      createdAt
    }
  }
}

Liquid payload triage

Replace limit loops with paginate

limit reduces rendered items, not always the fetched object volume. For heavy templates, paginate is the safer baseline.

{% paginate collection.products by 12 %}
  <ul class="product-grid" role="list">
    {% for product in collection.products %}
      {% render 'card-product', product: product %}
    {% endfor %}
  </ul>
{% endpaginate %}

Prevent nested snippet loops from multiplying work

Common anti-pattern:

{% for product in collection.products limit: 12 %}
  {% render 'card-product-extended', product: product, show_quick_view: true, show_bundle: true %}
{% endfor %}

If card-product-extended internally loops variants/media/metafields, every card multiplies server render and DOM weight. Split non-critical card features behind interaction.

LCP containment for above-the-fold media

Force deterministic hero rendering

<div class="hero-media">
  {{ section.settings.hero_image
    | image_url: width: 1800
    | image_tag:
      widths: '480,768,1200,1600,1800',
      sizes: '(max-width: 768px) 100vw, 1600px',
      fetchpriority: 'high',
      loading: 'eager',
      width: 1600,
      height: 900,
      alt: section.settings.heading
  }}
</div>
.hero-media {
  aspect-ratio: 16 / 9;
  overflow: clip;
}

Preload only the true LCP image

<link
  rel="preload"
  as="image"
  href="{{ section.settings.hero_image | image_url: width: 1600 }}"
  imagesrcset="{{ section.settings.hero_image | image_url: width: 768 }} 768w, {{ section.settings.hero_image | image_url: width: 1200 }} 1200w, {{ section.settings.hero_image | image_url: width: 1600 }} 1600w"
  imagesizes="(max-width: 768px) 100vw, 1600px"
>

Never preload multiple candidate heroes. One wrong preload can increase LCP.

App script isolation strategy

Scope scripts by template

UI path: Online Store > Themes > Customize > App embeds.

{%- liquid
  assign enable_reviews = false
  if template.name == 'product'
    assign enable_reviews = true
  endif
-%}

{% if enable_reviews %}
  <script src="{{ 'reviews-widget.js' | asset_url }}" defer></script>
{% endif %}

Delay non-conversion scripts to idle

<script>
  (function () {
    var loaded = false;

    function loadMarketingStack() {
      if (loaded) return;
      loaded = true;
      var s = document.createElement('script');
      s.src = 'https://cdn.example.com/marketing-stack.js';
      s.async = true;
      document.head.appendChild(s);
    }

    window.addEventListener('load', function () {
      if ('requestIdleCallback' in window) {
        requestIdleCallback(loadMarketingStack, { timeout: 1500 });
      } else {
        setTimeout(loadMarketingStack, 1200);
      }
    }, { once: true });
  })();
</script>

Stop CLS from app and dynamic UI mounts

Reserve layout slots for async widgets

<div id="reco-slot" class="reco-slot" aria-live="polite"></div>
.reco-slot {
  min-height: 280px;
}

@media (min-width: 1024px) {
  .reco-slot {
    min-height: 340px;
  }
}

If a widget can collapse, reserve minimum height until mount completes.

Technical Checklist

SEO Checklist