Most slow Shopify stores are not slow because of the theme. They are slow because of the apps added to it: reviews, chat, email pop-ups, push notifications, restock alerts. Each app puts a script in the page. Each script loads before the page shows. Shoppers wait, and some leave.
We did this work recently on a live fashion store in two languages. The mobile PageSpeed score was in the mid-40s. The theme code was fine. Seven scripts from apps loaded before the page. This post shows what we did, with the code.
Find the heavy parts first
Test your store on mobile withPageSpeed Insights. Open the treemap view. It shows the size of everything the page loads. Sort by size and look for names you know. Those names are apps, not theme code.
On our store, the heaviest item was a reviews widget. It loaded about 2 MB of oversized images on the home page. No theme change can beat that. You fix the app, replace it, or make it load later.
Do this step first. If you delay a script that was never heavy, you win nothing.
The pattern: hold the scripts until the shopper moves
Chat, email pop-ups, and push prompts share one property: nobody needs them in the first second. A shopper who never taps or scrolls never needed them at all. So we hold these scripts until the first tap or scroll. The page shows first.
There is a catch on Shopify. Apps inject these scripts, so you cannot adddefer to a tag you do not write. Instead, stop each script when the page creates it. Put this as the first script in the head oftheme.liquid:
<script>
(function () {
// Hosts to hold until first interaction. Add your own offenders.
var DELAY = ['chatra', 'klaviyo', 'pushowl', 'instafeed'];
var parked = [];
var released = false;
function matches(src) {
if (released || typeof src !== 'string') return false;
for (var i = 0; i < DELAY.length; i++) {
if (src.indexOf(DELAY[i]) !== -1) return true;
}
return false;
}
// Hook property assignment: el.src = '...'
var proto = HTMLScriptElement.prototype;
var desc = Object.getOwnPropertyDescriptor(proto, 'src');
Object.defineProperty(proto, 'src', {
get: desc.get,
set: function (value) {
if (matches(value)) {
parked.push({ el: this, src: value });
return; // do not set src yet; the browser never fetches it
}
desc.set.call(this, value);
},
});
// Hook attribute assignment: el.setAttribute('src', '...')
var setAttr = proto.setAttribute;
proto.setAttribute = function (name, value) {
if (name === 'src' && matches(value)) {
parked.push({ el: this, src: value });
return;
}
return setAttr.call(this, name, value);
};
function release(trigger) {
if (released) return;
released = true;
window.__delayReleasedBy = trigger; // leave a debugging trail
for (var i = 0; i < parked.length; i++) {
desc.set.call(parked[i].el, parked[i].src);
}
parked = [];
}
['pointerdown', 'keydown', 'touchstart', 'scroll'].forEach(function (evt) {
addEventListener(evt, function () { release(evt); }, { once: true, passive: true });
});
// Safety net: a shopper who never interacts still gets everything.
setTimeout(function () { release('timeout'); }, 8000);
})();
</script>Three details matter in production:
- Hook both paths. Some apps set
el.src. Other apps callsetAttribute('src', ...). Our first version had only the first hook, and one app got through. - Leave a trail.
window.__delayReleasedByrecords what started the load. When something looks wrong, it answers one question from any console: did the release occur, and why. - Always keep the timeout. Chat and analytics must load for a shopper who only reads. Eight seconds is late enough.
Think before you add analytics or consent tools to the delay list. A delayed analytics script does not measure the start of the visit.
Fonts: half a second for free
Examine each @font-face in the theme. Iffont-display is missing or set to fallback, the text can stay invisible while the font downloads. Setfont-display: swap on every font face. The text then shows immediately in a system font, and the correct font replaces it.
On our store, this removed about 0.5 seconds of hidden text across six font faces. It is the cheapest fix in this post.
Remove apps the theme can replace
The best performance win is an app that is gone. Our store paid for a translation app. Its only remaining job was French labels on size options. We moved those labels into the theme with a small Liquid snippet. One subscription gone, one script gone from every page.
Go through your app list. Ask one question per app: what breaks tomorrow if this app is gone? If the answer is "a lot", keep it. If the answer is "one small thing the theme can do", replace it. If the answer is "nothing", remove it today.
What moved
Scores move in steps, not jumps. The interceptor and the font fix moved the mobile PageSpeed score from the mid-40s toward the 50s. The page also responds faster to taps, because seven scripts no longer compete for the processor at the start.
The reviews widget stays the limit. Its 2 MB of images is a problem for the app vendor, not for the theme. That is the honest shape of this work: measure, move the heaviest thing to later, remove what you can, and know which limits belong to which vendor.