Category: Performance

  • Core Web Vitals for Shopify: a practical fix list

    Core Web Vitals for Shopify: a practical fix list

    Most Core Web Vitals advice for Shopify is either “install a speed app” or a wall of theory. This is the middle version: what each metric means for a storefront, and the specific changes that move it, ordered by impact.

    Before you start, get real numbers. The Shopify speed score in your admin is a lab test on a synthetic device — useful for spotting regressions, useless for knowing what your customers experience. Use the Chrome UX Report data in PageSpeed Insights, which reflects real visits to your store over the previous 28 days.

    LCP — Largest Contentful Paint

    What it measures: how long until the biggest thing above the fold has painted. On almost every Shopify homepage this is the hero image. On a product page it is usually the main product photo.

    Target: under 2.5 seconds for 75% of visits.

    Fixes, in order of payoff

    1. Stop lazy-loading your hero. This is the most common own-goal in Shopify theming. loading="lazy" on the LCP image tells the browser to deprioritise the one thing the metric measures. Your hero should be loading="eager" with fetchpriority="high". Everything below the fold should be lazy.

    2. Serve an image sized for the device. A 2400px hero delivered to a 390px phone is roughly 30× more pixels than needed. Shopify’s image_url filter with a proper srcset and a correct sizes attribute solves this:

    {% assign img = section.settings.hero_image %}
    <img
      src="{{ img | image_url: width: 1200 }}"
      srcset="{{ img | image_url: width: 480 }} 480w,
              {{ img | image_url: width: 800 }} 800w,
              {{ img | image_url: width: 1200 }} 1200w,
              {{ img | image_url: width: 1800 }} 1800w"
      sizes="(min-width: 1024px) 100vw, 100vw"
      width="{{ img.width }}"
      height="{{ img.height }}"
      loading="eager"
      fetchpriority="high"
      alt="{{ img.alt | escape }}">

    The sizes attribute is the part people get wrong. If it does not describe the real rendered width, the browser picks the wrong candidate and all the work above is wasted.

    3. Preload the hero. One line in theme.liquid, inside the {% if template == 'index' %} branch so you are not preloading a homepage image on every page.

    4. Audit your app scripts. Open DevTools → Network, filter to JS, sort by size. Anything over 50KB that is not your theme deserves a question. Apps you trialled and abandoned frequently leave script tags behind — check Online Store → Themes → Edit code for orphaned snippets.

    5. Self-host fonts or use font-display: swap. A render-blocking font request from a third-party origin adds a connection setup you cannot control. If the hero text is the LCP element, this is directly on the critical path.

    INP — Interaction to Next Paint

    What it measures: how long the page takes to visibly respond after a tap or click. It replaced First Input Delay because FID only measured the first interaction and only measured the delay, not the response.

    Target: under 200ms for 75% of visits.

    This is the metric Shopify stores fail most often, and it is almost always apps. Every app that adds a global click listener, every chat widget that boots on load, every review widget that re-renders the page — they all compete for the same main thread that needs to handle the tap.

    Fixes

    1. Count your apps honestly. Open your storefront and list every third-party feature on the page. For each one, ask whether it earns its place. The median store we audit is running two apps it forgot it installed.

    2. Defer everything non-critical. Chat widgets, review carousels, popups and analytics should not load during the initial render. Most modern apps support an idle-load or on-interaction mode; a surprising number just do not have it turned on.

    3. Break up long tasks. If you have custom JavaScript doing work over a large collection, chunk it. Anything holding the main thread for more than 50ms is a long task and directly hurts INP.

    4. Watch for layout thrash in sticky headers. Reading getBoundingClientRect() in a scroll handler and then writing a style forces synchronous layout on every frame. Batch reads and writes, or use IntersectionObserver instead.

    CLS — Cumulative Layout Shift

    What it measures: how much visible content jumps around while the page loads.

    Target: under 0.1.

    CLS is the easiest of the three to fix because the causes are finite:

    • Images without dimensions. Always set width and height (or a CSS aspect-ratio). The browser then reserves the box before the file arrives.
    • Web fonts swapping. A fallback font with different metrics reflows the text when the real font lands. Use size-adjust and ascent-override in your @font-face to match metrics, or a tool that generates them.
    • Injected banners. Announcement bars, cookie notices and free-shipping bars that insert themselves at the top of the DOM after paint push everything down. Reserve their height in CSS or render them server-side.
    • Embedded content. Reviews, Instagram feeds and video embeds that expand after loading. Give them a min-height.

    What to do this week

    If you only have an afternoon:

    1. Run PageSpeed Insights on your homepage, a collection page and your best-selling product page. Record the field data numbers.
    2. Fix loading and fetchpriority on your hero image.
    3. Add width and height to every image in your theme that lacks them.
    4. Uninstall one app you are not using, and check the theme code for what it left behind.
    5. Re-measure in 28 days, because field data is a rolling window and will not move overnight.

    That list has, in our experience, moved more stores from red to green than any speed-optimisation app ever has.

    If you want to know what a small app looks like, all six of ours publish their bundle size on their pages. Ours are under 30KB. Most are not.

  • How to audit app bloat on your Shopify store in 30 minutes

    How to audit app bloat on your Shopify store in 30 minutes

    The average Shopify store runs six apps. The average store we audit is running two it forgot about, one that was uninstalled but left code behind, and one that loads 200KB to display a badge.

    This is the audit we run. It takes about half an hour and you need nothing but a browser.

    Step 1: Inventory what actually loads (10 minutes)

    Open your storefront homepage in Chrome. Open DevTools, go to the Network tab, tick Disable cache, and reload.

    Now:

    1. Filter to JS.
    2. Sort by Size, descending.
    3. Screenshot it.

    You are looking at every script your storefront loads, biggest first. Your theme’s own JavaScript is usually near the top and that is fine. Everything else needs a justification.

    Repeat this on a product page and a collection page. Some apps only load on specific templates, and some load everywhere when they should not.

    What to write down: for each script over 30KB, the domain it came from and its size. The domain tells you the app.

    Step 2: Find the orphans (5 minutes)

    Uninstalling an app in Shopify admin removes its app embed. It does not always remove code that was injected into your theme — particularly for apps installed before app embeds existed, or apps that asked you to paste a snippet.

    In your admin:

    1. Online Store → Themes → ⋯ → Edit code
    2. Open layout/theme.liquid and read the <head> and the bottom of <body> carefully.
    3. Look in snippets/ for files with vendor names you do not recognise.
    4. Search the whole theme for <script src= and for .myshopify.com/apps.

    Anything referencing an app you no longer use is dead weight — often a request that 404s, which is still a request.

    Before you delete anything, duplicate the theme. This is a two-click backup and you will want it.

    Step 3: Check the app embeds you do have (5 minutes)

    Online Store → Themes → Customize → App embeds (the plug icon in the left sidebar).

    Every embed here is loading on every page. Toggle off anything you are not actively using. You do not have to uninstall the app to stop it loading — this is useful for seasonal apps.

    Step 4: Classify each app (10 minutes)

    For every app still standing, put it in one of four buckets:

    Keep as-is. It is small, it is used, it earns its weight.

    Keep but defer. It is used but it does not need to load during the initial render. Chat widgets, review widgets below the fold, popups, loyalty widgets. Check the app settings for a “load on interaction” or “defer” option — many have one that is off by default. If it does not have one, ask support. It is a reasonable request.

    Replace. It does something you need, but there is a lighter option. Sliders, badges, countdowns and popups all have a wide spread of implementation quality — the difference between the heaviest and lightest option in each category is frequently 5–10×.

    Remove. You are not using it, or the feature is not worth the weight. Be honest here. An app that produced a measurable lift when you installed it eighteen months ago may not be earning anything now.

    Step 5: Measure before and after

    Run PageSpeed Insights on your homepage before you change anything and screenshot the result. This is your baseline.

    Make your changes, then re-run the lab test immediately to confirm the direction. Do not expect the field data to move — that is a rolling 28-day window of real visits and it will take about a month to reflect the change.

    What “good” looks like

    For a mid-sized store on a modern theme:

    MetricTarget
    Total JS on homepageUnder 300KB compressed
    Third-party JSUnder 100KB compressed
    Requests to non-Shopify domainsUnder 10
    LCP (field, mobile)Under 2.5s
    INP (field, mobile)Under 200ms

    If your third-party JavaScript is more than your theme’s, you have found your problem.

    The uncomfortable part

    Most stores can remove two apps without anyone noticing. The reason they do not is that each app individually seems defensible — it does something, someone installed it for a reason, removing it feels like a loss.

    The cost is not per-app though. It is cumulative, it is paid by every visitor on every page load, and it is invisible in your admin. Nobody sends you a report saying “your reviews widget cost you 140 sales this month by pushing your INP over 200ms.”

    That is what makes this audit worth thirty minutes a quarter.

    What we do about it

    Every app we build publishes its compressed bundle size on its page, and we treat that number as a product constraint rather than an outcome. It is why our apps do fewer things than the suites they compete with — and why installing three of ours is still lighter than installing one of theirs.

    You can see the numbers on the apps page. Compare them to what you are running now.