A Shopify Lighthouse score moves on four things: when the LCP image starts downloading, how much JavaScript blocks the main thread, what the browser receives before content_for_header, and layout shift. Fix those in the theme and the number follows.
Is a low Shopify Lighthouse score actually a problem?
Lighthouse is a lab test. It loads one page, once, on an emulated mid-range phone with a throttled connection, and turns five metrics into a score out of 100. Chrome’s scoring documentation gives the weights: Total Blocking Time 30%, Largest Contentful Paint 25%, Cumulative Layout Shift 25%, First Contentful Paint 10% and Speed Index 10%. Two things follow from that table. JavaScript dominates, because Total Blocking Time is the largest single slice. And Interaction to Next Paint, the Core Web Vital that measures how quickly the page responds to a tap, is not in the score at all.
Your customers are measured somewhere else. The web performance reports in the Shopify admin show real-user LCP, INP and CLS at the 75th percentile over the last 90 days, graded Good at LCP of 2.5 seconds or less, INP of 200 ms or less and CLS of 0.1 or less. Those match the thresholds Google publishes for Core Web Vitals. Shopify’s own lab versus field guide says to use field data for the baseline and for deciding what to optimise, and lab data for debugging and before-and-after comparisons.
So a store can score 45 in Lighthouse and still pass all three field thresholds, if its real visitors use faster phones and connections than the emulated device. The reverse also happens. Read the admin report first. If it is green, the Lighthouse score is a debugging aid, not a target.
The one place the lab score is a hard gate is the Theme Store. Its requirements ask for an average Lighthouse performance score of at least 60 across the home, product and collection pages, on desktop and mobile.
How do you get a Lighthouse score you can trust?
Single runs are noisy. Ads, routing and the test machine all shift the result, and Chrome’s docs suggest treating performance as a distribution rather than one number. Shopify’s testing guide sets a method: test the home, product and collection templates, run each URL at least three times, take the median, and append pb=0 to theme preview URLs so the preview bar does not skew the audit.
#!/usr/bin/env bash
# Median-of-three mobile Lighthouse runs for the three templates Shopify scores.
set -euo pipefail
STORE="https://your-store.myshopify.com"
PAGES=("/" "/products/example-product" "/collections/all")
for path in "${PAGES[@]}"; do
scores=()
for run in 1 2 3; do
npx lighthouse "${STORE}${path}?pb=0" \
--only-categories=performance \
--output=json --output-path=./lh.json --quiet \
--chrome-flags="--headless"
scores+=("$(jq '.categories.performance.score * 100 | round' ./lh.json)")
done
median=$(printf '%s\n' "${scores[@]}" | sort -n | sed -n '2p')
echo "${path}: ${scores[*]} -> median ${median}"
done
Lighthouse emulates mobile by default; add --preset=desktop for the desktop pass. To stop regressions reaching the live theme, Shopify maintains lighthouse-ci-action, which runs Lighthouse against a preview of the theme on each push or pull request. Its lhci_min_score_performance input defaults to 0.6, the Theme Store bar. Set it a few points under your current median and raise it as the score improves. It pairs well with the lint step from our Theme Check in CI write-up.
Why is the LCP image loading late?
On a typical product or home page the LCP element is the hero or the first product image, and the usual fault is that the browser finds out about it too late. Shopify’s fetchpriority guide explains the delay: images start at Low priority, and the browser only upgrades the ones in the viewport after layout. A lazy-loaded hero is worse, because it waits for layout before it is even requested. CSS background images have the same problem, which is why Shopify’s performance best practices say to use an <img> for hero content.
The image_tag filter already does the careful thing if you let it. When you pass no loading argument, it loads eagerly for the first three sections and whenever section.index0 is nil, and lazily after that. Theme code that hard-codes loading: 'lazy' on every image, or gates it with section.index <= 2, breaks that. The section.index guide spells out the trap: section.index is nil in the theme editor, in static sections and through the Section Rendering API, and nil <= 2 is false in Liquid, so the hero falls into the lazy branch.
Write the condition so nil lands on the eager side, and give fetchpriority: 'high' to the first section’s image only:
{%- liquid
assign hero = section.settings.image
assign hero_sizes = '(min-width: 1000px) 900px, calc(100vw - 2rem)'
-%}
{%- if section.index == 1 -%}
{{ hero | image_url: width: 1800 | image_tag: loading: 'eager', fetchpriority: 'high', widths: '600, 900, 1200, 1800', sizes: hero_sizes }}
{%- elsif section.index > 3 -%}
{{ hero | image_url: width: 1800 | image_tag: loading: 'lazy', widths: '600, 900, 1200, 1800', sizes: hero_sizes }}
{%- else -%}
{{ hero | image_url: width: 1800 | image_tag: loading: 'eager', widths: '600, 900, 1200, 1800', sizes: hero_sizes }}
{%- endif -%}
One fetchpriority="high" per page. Shopify’s guide warns that marking several images high interferes with the browser’s own prioritisation, which undoes the point. The mechanics of the attribute itself are in our fetchpriority and LCP post.
image_tag also writes width and height from the image’s own dimensions, which reserves the box and keeps CLS down. Hand-built <img> tags with no dimensions get none of that, and every one of them can shift the layout when it arrives.
Which scripts are blocking the main thread?
Total Blocking Time is 30% of the score, and on a Shopify storefront the JavaScript behind it comes from two places: the theme’s own scripts and whatever the installed apps add. Run Theme Check’s ParserBlockingScript check first. It flags any <script src> in the theme without defer or async, at error severity by default. The rule of thumb from that page: defer when execution order matters, async when it does not.
Theme Check cannot see scripts that apps inject. To measure their cost, turn app embeds off in the theme editor on a duplicate theme, rerun the median-of-three script, and compare. The difference is what the apps cost in score points, and that number makes a far better case to a merchant than “remove some apps”. An app whose widget sits below the fold has no reason to run during load.
Your own feature code can come off the critical path too. The best practices page recommends a dynamic import() inside an event listener. A size chart or a reviews drawer then downloads only when someone opens it:
<button type="button" data-size-chart data-module="{{ 'size-chart.js' | asset_url }}">
Size guide
</button>
<script src="{{ 'size-chart-trigger.js' | asset_url }}" defer></script>
// assets/size-chart-trigger.js
// The heavy module is fetched on first click and reused afterwards.
const button = document.querySelector('[data-size-chart]');
let chartModule;
button?.addEventListener('click', async () => {
chartModule ??= import(button.dataset.module);
const { openSizeChart } = await chartModule;
openSizeChart(button);
});
This helps the field INP number as well as the lab score, since less script evaluates while the visitor is trying to tap. Our INP guide covers the interaction side.
What belongs above content_for_header?
Shopify streams the storefront response. According to the content_for_header guide, everything in your layout from <!doctype html> down to {{ content_for_header }} reaches the browser while the sections are still rendering on the server. The rest arrives once rendering finishes.
That gives the browser a head start on whatever you place above the tag: the stylesheet for the first viewport, font preloads through preload_tag, @font-face rules from font_face, and import maps or module preloads. Move a blocking stylesheet below content_for_header and you lose that window; move an inline script that reads window.Shopify above it and you break the script, because that object is output by the tag. Leave content_for_header itself alone. Shopify’s guide says to keep it a plain output tag inside <head> with no filters, conditionals or wrappers.
Keep preloads to one or two late-discovered resources. The best practices page is explicit that overusing preload competes with the browser’s own prioritisation. For fonts, the same page recommends uploading them to the Shopify CDN instead of loading them from Google Fonts.
What we would do on a slow store
Start with the admin’s web performance report. If field LCP, INP and CLS are green, spend the time elsewhere; a lab score in the 40s on a store that passes in the field is not a commercial problem. If one is red, use Lighthouse to find out why, with median-of-three runs on the three templates.
Then work in this order. Let image_tag decide lazy loading and add fetchpriority to the first section’s image. Measure the app embeds and remove or defer the expensive ones. Add defer to every theme script, and move first-paint CSS above content_for_header. Gate the result in CI so it does not drift back.
We would not rebuild a theme, or go headless, to chase a lab score. If the field data is good, the theme is doing its job.
Whoooop fixes slow Shopify themes as part of our Shopify development work, from Liquid image handling to working out which apps earn their place. If the admin report is red and the cause is not obvious, that is a job we take on.