Last quarter we took a client's Shopify store from a PageSpeed Insights score of 35 to 92. Mobile. On a theme with 14 apps installed. This article is the exact process we followed, written as a checklist you can use on your own stores.
A few things before we start. Shopify is a hosted platform — you don't control the server, the CDN config, or the TLS handshake. That means roughly 30% of your total page load time is outside your control. The good news: the remaining 70% has so much low-hanging fruit that most stores can double their performance score without touching anything architectural.
We do this work regularly at MILEDEVS as part of our Shopify development services. Every recommendation below comes from production stores, not lab conditions.
1. Image Optimization — The Biggest Win
Images account for 50-80% of total page weight on a typical Shopify store. Fix this first.
Use Shopify's Built-in Image CDN Properly
Shopify automatically serves images through their CDN, but only if you use the image_url filter instead of hardcoded URLs:
{% comment %} Bad: hardcoded, no CDN optimization {% endcomment %}
<img src="{{ product.featured_image | img_url: 'master' }}" />
{% comment %} Good: responsive, WebP-auto, CDN-optimized {% endcomment %}
<img
src="{{ product.featured_image | image_url: width: 600 }}"
srcset="
{{ product.featured_image | image_url: width: 300 }} 300w,
{{ product.featured_image | image_url: width: 600 }} 600w,
{{ product.featured_image | image_url: width: 900 }} 900w,
{{ product.featured_image | image_url: width: 1200 }} 1200w
"
sizes="(max-width: 768px) 100vw, 50vw"
loading="lazy"
decoding="async"
width="{{ product.featured_image.width }}"
height="{{ product.featured_image.height }}"
alt="{{ product.featured_image.alt | escape }}"
/>
Key details:
-
image_url(notimg_url) automatically converts to WebP when the browser supports it - The
widthandheightattributes prevent layout shift — the browser reserves space before the image loads -
loading="lazy"defers off-screen images. Do NOT lazy-load the first visible image (hero, first product in grid) — that will hurt LCP -
decoding="async"lets the browser decode images off the main thread
Upload Source Images at 2x Maximum Display Size
If your product images display at 600px, upload at 1200px. Not 4000px. Shopify resizes on the fly, but it still has to process larger source files, and you'll sometimes see the original loaded by poorly written app code or social sharing previews.
Our result: Replacing img_url: 'master' with properly sized image_url across a fashion store reduced page weight from 8.2 MB to 1.4 MB. LCP dropped from 6.1s to 2.3s.
2. JavaScript Audit — Find and Kill What You Don't Need
Identify the Bloat
Open Chrome DevTools, go to the Coverage tab (Ctrl+Shift+P, type "Coverage"), reload the page. The red bars show unused bytes. On a typical Shopify store, 60-70% of loaded JavaScript is unused on any given page.
The usual culprits:
| Source | Typical Size | Often Unnecessary On |
|---|---|---|
| App scripts | 200-800 KB | Pages where the app isn't used |
| Theme vendor.js | 100-300 KB | Contains unused jQuery plugins |
| Analytics (GA4, Meta, TikTok) | 150-400 KB | N/A (but can be deferred) |
| Chat widgets | 200-500 KB | Every page except where needed |
Defer Non-Critical Scripts
Shopify's content_for_header outputs all app scripts synchronously. You can't change that directly, but you can defer your own theme scripts:
{% comment %} layout/theme.liquid {% endcomment %}
{% comment %} Critical: render-blocking, needed for first paint {% endcomment %}
<script src="{{ 'critical.js' | asset_url }}" defer></script>
{% comment %} Non-critical: load after page is interactive {% endcomment %}
<script>
window.addEventListener('load', function() {
var scripts = [
'{{ "deferred-features.js" | asset_url }}',
'{{ "reviews-widget.js" | asset_url }}'
];
scripts.forEach(function(src) {
var s = document.createElement('script');
s.src = src;
s.async = true;
document.body.appendChild(s);
});
});
</script>
Remove jQuery If You Can
Dawn and most modern Shopify themes don't require jQuery. If your theme still loads it, check whether any critical functionality depends on it. Many stores load jQuery (90 KB minified) solely because one old app snippet uses $('.something'). Replace those few lines with vanilla JS and drop the entire library.
// Before (requires jQuery)
$('.accordion-toggle').click(function() {
$(this).next('.accordion-content').slideToggle();
});
// After (vanilla JS, 0 dependencies)
document.querySelectorAll('.accordion-toggle').forEach(function(toggle) {
toggle.addEventListener('click', function() {
const content = this.nextElementSibling;
content.style.display = content.style.display === 'none' ? 'block' : 'none';
});
});
3. Liquid Profiling — Find Slow Templates
Enable Shopify's Liquid Profiler
Add ?_fd=0&pb=0 to any page URL on your development store to see raw Liquid render times in the response. For more detailed profiling:
https://your-store.myshopify.com/?profile_liquid=true
This adds HTML comments showing render time for every snippet and section. Look for anything over 5ms — that's slow for a Liquid partial.
Common Liquid Performance Traps
Trap 1: Nested loops over large collections
{% comment %} SLOW: O(n*m) — checks every product against every tag {% endcomment %}
{% for product in collections.all.products %}
{% for tag in product.tags %}
{% if tag == 'featured' %}
{% render 'product-card', product: product %}
{% endif %}
{% endfor %}
{% endfor %}
{% comment %} FAST: Use a filtered collection instead {% endcomment %}
{% for product in collections.featured.products limit: 12 %}
{% render 'product-card', product: product %}
{% endfor %}
Trap 2: Rendering the same snippet hundreds of times in a loop
Every {% render %} call has overhead. If you're rendering a product card 50 times in a collection page, make sure the snippet itself is lean — no nested render calls, no redundant assign statements.
Trap 3: Using where filter on large arrays
{% comment %} SLOW on arrays with 1000+ items {% endcomment %}
{% assign filtered = product.metafields.custom.related | where: "status", "active" %}
{% comment %} FASTER: filter server-side via collection or metafield definition {% endcomment %}
4. App Audit — The Hidden Performance Tax
Every Shopify app injects code into your storefront. Most store owners have no idea how much.
The Audit Process
- List all installed apps — go to Settings > Apps and sales channels
-
Check which apps inject frontend code — in theme editor, look at
content_for_headeroutput (View Source, search for<script) - Test removal impact — disable apps one at a time and measure PageSpeed after each
What We Typically Find
On the store we took from 35 to 92, here's what the app audit revealed:
- Review app: Loading 340 KB of JS on every page, including pages with no reviews. Fix: load conditionally only on product pages using Shopify's app embed controls.
- Currency converter: 180 KB. The theme had built-in currency support. App was redundant. Removed.
-
Exit-intent popup: 220 KB. Replaced with a 3 KB custom implementation using
mouseleaveevent. - Two abandoned analytics apps the client had forgotten about: 280 KB combined. Removed.
Total savings: 1,020 KB of JavaScript. That alone moved the score from 35 to 58.
Replace Heavy Apps With Lightweight Code
Many common app features can be replicated with 20-50 lines of Liquid and JavaScript:
-
Announcement bars — a
<div>with inline styles and a dismiss cookie - Back-in-stock buttons — a form that posts to a Shopify Flow webhook
- Size charts — a modal with a static HTML table
- Trust badges — SVG icons in a snippet
Don't replace complex apps (reviews, subscriptions, loyalty programs) — those have backend logic that justifies their weight. But purely frontend features often don't need 200 KB of vendor JavaScript.
5. Core Web Vitals — The Metrics That Matter
Google uses three Core Web Vitals for ranking: LCP (Largest Contentful Paint), INP (Interaction to Next Paint), and CLS (Cumulative Layout Shift).
LCP: Target Under 2.5 Seconds
The largest contentful paint is almost always your hero image or the first product image. To optimize:
{% comment %} Preload the hero image — tells the browser to fetch it immediately {% endcomment %}
{% if template.name == 'index' %}
<link
rel="preload"
as="image"
href="{{ section.settings.hero_image | image_url: width: 1200 }}"
type="image/webp"
fetchpriority="high"
/>
{% endif %}
Also add fetchpriority="high" to the hero <img> tag itself:
<img src="..." fetchpriority="high" loading="eager" />
Do NOT lazy-load your LCP element. This is the single most common mistake we see. The loading="lazy" attribute tells the browser to deprioritize the image — the exact opposite of what you want for LCP.
INP: Target Under 200ms
Interaction to Next Paint measures how fast the page responds to user input. Common fixes:
-
Break up long tasks. If a click handler does heavy DOM manipulation, use
requestAnimationFrameorscheduler.yield()to avoid blocking the main thread for more than 50ms. - Debounce search inputs. Don't fire a search query on every keystroke.
-
Use
content-visibility: autoon below-fold sections to reduce rendering work.
/* Skip rendering for off-screen sections */
.section--below-fold {
content-visibility: auto;
contain-intrinsic-size: 0 500px;
}
CLS: Target Under 0.1
Layout shift happens when elements move after the page has started rendering. Fixes:
- Always set width/height on images (covered in Section 1)
- Reserve space for app embeds — if a reviews widget loads asynchronously, wrap its container in a div with a min-height
-
Font loading — use
font-display: swapand preload your primary font:
<link
rel="preload"
href="{{ 'custom-font.woff2' | asset_url }}"
as="font"
type="font/woff2"
crossorigin
/>
@font-face {
font-family: 'CustomFont';
src: url('custom-font.woff2') format('woff2');
font-display: swap;
}
The Full Checklist
Here's the condensed version you can copy into your project management tool:
- [ ] Replace all
img_urlwithimage_url+ srcset + sizes - [ ] Add width/height attributes to every
<img>tag - [ ] Set
loading="lazy"on all images EXCEPT the LCP element - [ ] Preload hero/LCP image with
fetchpriority="high" - [ ] Run JS coverage analysis — identify unused bytes
- [ ] Defer all non-critical theme JavaScript
- [ ] Remove jQuery if no critical features depend on it
- [ ] Audit all installed apps — remove unused ones
- [ ] Check which apps load JS globally vs. per-page
- [ ] Replace simple app features with lightweight code
- [ ] Profile Liquid render times — fix templates over 5ms
- [ ] Eliminate nested loops over large collections
- [ ] Add
content-visibility: autoto below-fold sections - [ ] Preload primary web font
- [ ] Test with PageSpeed Insights on mobile (not just desktop)
- [ ] Verify Core Web Vitals in Google Search Console (field data, not lab)
Measuring Results
Lab scores (Lighthouse, PageSpeed Insights) are useful for development, but field data from Chrome UX Report (CrUX) is what Google uses for ranking. After deploying performance changes, wait 28 days for CrUX data to update before declaring victory.
Check your field data in Google Search Console under Core Web Vitals, or use the CrUX API directly:
curl "https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"origin": "https://your-store.com"}'
The store we optimized from 35 to 92 saw its CrUX "good URLs" percentage go from 12% to 89% over the following month. Organic traffic increased 23% in the same period — not entirely attributable to performance, but the correlation was strong.
Final Thoughts
Performance optimization on Shopify is not a one-time project. Every new app install, every theme update, every seasonal campaign can regress your scores. Build performance checks into your development workflow — run Lighthouse in CI, monitor CrUX monthly, and audit apps quarterly.
If you want a team to handle this end-to-end, we do exactly this at MILEDEVS. But this checklist gives you everything you need to do it yourself. Start with images and the app audit — those two alone will get you 80% of the gains.
Top comments (0)