Why Your Shopify Theme Scores 40 on Lighthouse (And the Order to Fix It In)
Most Shopify performance advice is a list of tips in no particular order. Here is how to measure first, find the actual cause, and fix things in the order that matters.
Disclosure: I spent four years building Shopify themes at Debutify, finishing as CDO. The numbers here come from profiling real merchant stores during that time.
The short answer
A clean Shopify theme with no apps scores in the high 80s on mobile. If you are at 40, roughly 700KB to 1.2MB of third party JavaScript arrived from apps, and one or two of them are responsible for most of it.
Do this in order:
- Measure what you actually ship, do not guess
- Find the single largest third party script and question whether the app earns its cost
- Fix your Largest Contentful Paint image, which is almost always the hero or the first product image
- Defer everything that is not needed for first render
- Only then start micro optimising Liquid
Most guides start at step five. Step five is worth maybe three points.
First, measure properly
The single most common mistake is optimising against the wrong number.
Lighthouse in Chrome DevTools is not your score. It runs on your machine, your network, your CPU, with your extensions. Run it in an incognito window at minimum. Better, use PageSpeed Insights, which runs on standardised hardware.
The number that actually matters is field data, not lab data. At the top of a PageSpeed Insights report, if your store has enough traffic, you get Chrome User Experience Report data. That is real Core Web Vitals from real visitors on real devices, and it is what Google uses for ranking. The lab score below it is a diagnostic tool, not a grade.
I have watched merchants spend weeks moving a lab score from 42 to 58 while their field Interaction to Next Paint stayed bad, because the thing hurting real users was a chat widget that only loads after five seconds and never appears in a lab run.
Test the right pages. Your homepage is not your most important page. Test a product page and a collection page, because that is where traffic lands and where conversion happens. Product pages usually carry the most app scripts.
Test on a throttled mobile connection. Your customers are not on your office wifi. Shopify's own data consistently shows the majority of storefront traffic is mobile.
Step one: find what you are shipping
Open the product page, DevTools, Network tab, disable cache, reload. Sort by transfer size.
Then filter to JS and look at the domains. On a typical struggling store you get something like:
cdn.shopify.com 142 KB theme + Shopify core
static.klaviyo.com 218 KB email capture
cdn.judge.me 96 KB reviews
widget.tidio.co 184 KB live chat
cdn.rebuyengine.com 131 KB upsells
static.hotjar.com 89 KB session recording
connect.facebook.net 74 KB pixel
googletagmanager.com 112 KB GTM plus whatever is inside it
That is roughly 900KB of third party JavaScript against 142KB of theme. The theme is not your problem. I want to be blunt about this because most performance advice for Shopify merchants focuses on the theme, and on a store like this the theme is 14% of the JavaScript.
For a cleaner view of who is costing you main thread time, run a Performance profile and open the Bottom Up panel grouped by URL. That gives you script evaluation time per domain, which is what actually blocks interaction. A 200KB script that parses and executes for 400ms on a mid range Android phone is far worse than a 300KB script that runs in 40ms.
Step two: audit apps by cost
Every Shopify app that injects a script has a cost, and the cost is invisible in the app store listing.
Here is the measurement method that produces an honest answer:
- Duplicate your live theme so you have a safe copy
- In the duplicate, remove one app's script tag or app embed
- Run PageSpeed Insights on the same product page
- Record the delta
- Restore, repeat for the next app
Tedious, and it takes an afternoon. It also produces the only data that will settle an argument with whoever installed the app.
What I typically find on merchant stores:
Live chat widgets are the worst offenders by a wide margin. 150KB to 250KB, and they frequently load synchronously. Most merchants get a handful of chats a day. If you are getting fewer than twenty conversations a day, the widget is costing you more in abandoned sessions than it earns in support outcomes. At minimum, load it on interaction rather than on page load.
Review apps are usually worth their weight because reviews genuinely lift conversion, but many load the full widget on every page including pages with no reviews on them. Restrict them to product pages.
Session recording tools like Hotjar and Clarity are valuable during an investigation and pure cost the rest of the time. Turn them on for two weeks, learn something, turn them off. Merchants leave them running for years.
Upsell and bundle apps vary enormously. Some are well built. Some load an entire framework to render one component below the fold.
Abandoned or uninstalled apps leave residue. This one is genuinely common. An app is uninstalled but its script tag remains in theme.liquid, or its app embed is still enabled, or leftover Liquid snippets are still being rendered. Search your theme code for the app's name. I have found stores loading scripts for three apps that were uninstalled a year earlier.
Check your active script tags directly:
Admin, Online Store, Themes, Actions, Edit code
Search theme.liquid for <script. Anything hardcoded there that you cannot identify is a candidate for removal. Also check Settings, App embeds in the theme editor, since many modern apps inject through embeds rather than script tags.
Step three: fix your LCP image
Largest Contentful Paint is usually the metric holding your score down, and on a store it is almost always an image: the hero banner on the homepage, the first product image on a product page.
Make sure it is not lazy loaded. This is the most common single mistake I see. Themes apply loading="lazy" to all images through a snippet, including the hero. Lazy loading your LCP element delays it by a full round trip, and it is a guaranteed penalty.
{%- comment -%} Wrong for the hero {%- endcomment -%}
<img src="{{ section.settings.image | image_url: width: 1600 }}" loading="lazy">
{%- comment -%} Right {%- endcomment -%}
<img
src="{{ section.settings.image | image_url: width: 1600 }}"
srcset="
{{ section.settings.image | image_url: width: 600 }} 600w,
{{ section.settings.image | image_url: width: 1000 }} 1000w,
{{ section.settings.image | image_url: width: 1600 }} 1600w"
sizes="100vw"
width="{{ section.settings.image.width }}"
height="{{ section.settings.image.height }}"
loading="eager"
fetchpriority="high"
alt="{{ section.settings.image.alt | escape }}">
Four things matter there. loading="eager" on the LCP image, fetchpriority="high" so the browser prioritises it, explicit width and height to prevent layout shift, and a real srcset so phones do not download a 1600px image.
Preload it if it is discovered late, for example inside a slideshow section:
{%- if section.settings.image -%}
<link rel="preload" as="image"
href="{{ section.settings.image | image_url: width: 1600 }}"
imagesrcset="..." imagesizes="100vw">
{%- endif -%}
Let Shopify's CDN do the work. image_url: width: 1000 serves a correctly sized image and Shopify negotiates WebP automatically. Do not upload pre resized images and do not use a third party image CDN in front of Shopify's, which adds a DNS lookup and a connection for no benefit.
Do not put a carousel in your hero. Slideshows load multiple large images, most of which nobody sees, and they usually ship a JavaScript library to run. Every conversion study I have seen says visitors interact with slide one and ignore the rest. One static hero image is faster and converts at least as well.
Step four: defer what is not needed for first render
Move third party scripts off the critical path. Anything not needed for first paint should be defer at minimum:
<script src="..." defer></script>
Better, load on interaction. A chat widget does not need to exist until somebody indicates they might use it:
<script>
const loadChat = () => {
if (window.__chatLoaded) return;
window.__chatLoaded = true;
const s = document.createElement('script');
s.src = 'https://widget.example.com/chat.js';
s.async = true;
document.body.appendChild(s);
};
['pointerdown', 'keydown', 'touchstart'].forEach(evt =>
window.addEventListener(evt, loadChat, { once: true, passive: true })
);
// fallback so it exists for people who scroll and then want help
setTimeout(loadChat, 8000);
</script>
This pattern regularly moves a mobile score by ten to fifteen points on its own, and no customer notices, because the widget is ready long before anyone reaches for it.
Use Shopify.loadFeatures for Shopify's own optional bundles rather than pulling them in globally.
Be careful with Google Tag Manager. GTM itself is small. What people put inside it is not. A GTM container with fourteen tags firing on page load is one of the heaviest things on many stores, and it is invisible in your theme code because it all arrives at runtime. Audit the container, not just the loader.
Self host fonts, or use font-display: swap at minimum. Fonts loaded from an external origin add a DNS lookup, a TLS handshake, and a render blocking dependency. Shopify's font picker serves from the Shopify CDN, which is already connected, so prefer those.
Step five: theme and Liquid
Now, and only now, the theme.
Check your Liquid render time. Shopify sends a Server-Timing response header:
curl -sI https://yourstore.com/products/example | grep -i server-timing
server-timing: processing;dur=180, db;dur=45
Under about 200ms is healthy. Over 600ms means your Liquid is doing too much, and the usual cause is a loop hitting collections or metafields repeatedly. That deserves its own investigation.
Remove unused sections and snippets. Dead Liquid does not cost render time if it is not rendered, but dead CSS and JS in your bundles does.
Split CSS by template if your theme supports it. A product page does not need cart drawer styles.
Do not minify your way to a good score. Minification and combining files is the advice every generic guide leads with, and on a store carrying 900KB of app JavaScript it is worth almost nothing. Fix the apps first.
Realistic expectations
Numbers from stores I have worked on:
| Store state | Mobile Lighthouse |
|---|---|
| Clean Dawn, no apps | 88 to 96 |
| Well built theme, 3 or 4 lean apps, tuned | 70 to 82 |
| Typical merchant store, 8 to 12 apps | 30 to 50 |
| Same store after an app audit and LCP fix | 55 to 70 |
If someone promises you 95 on a real store with real apps, they are either removing functionality you need or they are testing the homepage on desktop and showing you that number.
Aim for the Core Web Vitals thresholds rather than a number. LCP under 2.5 seconds, INP under 200 milliseconds, CLS under 0.1. Those are what Google actually assesses, and a store can pass all three while scoring 65 in the lab.
The uncomfortable summary
Most Shopify performance problems are not theme problems. They are app problems, and fixing them means telling somebody that the app they chose is costing more than it earns.
That is a business conversation rather than a technical one, and it is the reason so much Shopify performance advice stays safely in the territory of minifying CSS. Minifying CSS does not require anyone to admit anything.
Measure the apps. Bring the numbers. The conversation goes much better with a table in front of it.