I've been scanning Shopify storefronts for the last few months across a sample of stores I've had access to through an app I shipped. Most of them fail Core Web Vitals on at least one metric, and the pattern is depressingly consistent: it's almost always the same three things, in the same order, and the fixes are usually less work than the merchants think.
The number that surprised me most: a large share of the product pages I've scanned have an LCP above 4 seconds on a throttled mobile profile. Above 2.5s is "needs improvement." Above 4s is "poor." That means a lot of merchants are paying for Google ads that land on a page Google itself considers slow, and it's costing them in two places at once — quality score and conversion.
This article is the long version of what I tell merchants when they ask me what to fix first. None of this is theoretical. I've shipped all of it.
Why Core Web Vitals actually matter on Shopify
I'll keep this short because most of it is well-trodden, but two things are worth pulling out for the Shopify context specifically.
First, ad cost. If you run Google Shopping or any kind of paid traffic, landing page experience feeds into Quality Score, which feeds into CPC. A slow PDP is a tax on every click. Merchants who fix LCP often report their cost-per-click drifting down over the following weeks — not always dramatically, but enough to notice on a budget over $500/day.
Second, conversion. The "100ms = 1% conversion" line gets thrown around like gospel and it's not really true at any specific number, but the directional finding is real. On mobile, going from a 5-second LCP to a 2.5-second LCP almost always lifts add-to-cart rate. The size of the lift depends on how desperate your customers were to buy in the first place. A coffee subscription with brand loyalty? Maybe 2%. A first-time visitor on a generic apparel store from a cold ad? Could be 8%.
Don't expect speed alone to fix a conversion problem caused by pricing, photography, or trust. Speed amplifies whatever you already have.
LCP: the hero image trap
The single most common LCP failure I see on Shopify is a 2400px-wide hero image being served to a 390px iPhone viewport, with no loading attribute, no srcset, and no width/height to reserve space.
The fix is built into Liquid. Most themes from the last two years use it correctly on collection grids, but somehow forget to apply it to the homepage hero or the PDP main image, which are exactly the two places it matters.
Here's the pattern I drop into custom sections:
{% liquid
assign image = section.settings.hero_image
assign widths = '375,540,750,1080,1500,2000'
%}
<img
src="{{ image | image_url: width: 1080 }}"
srcset="
{{ image | image_url: width: 375 }} 375w,
{{ image | image_url: width: 540 }} 540w,
{{ image | image_url: width: 750 }} 750w,
{{ image | image_url: width: 1080 }} 1080w,
{{ image | image_url: width: 1500 }} 1500w,
{{ image | image_url: width: 2000 }} 2000w
"
sizes="100vw"
width="{{ image.width }}"
height="{{ image.height }}"
alt="{{ image.alt | escape }}"
loading="eager"
fetchpriority="high"
>
Two things people miss:
fetchpriority="high"on the LCP image,loading="eager", and a preload<link>in<head>if you can get away with it. The preload is the cheat code. It pulls the hero image fetch up next to the HTML response instead of waiting for the renderer to discover it. Adding a preload for the hero typically pulls LCP down by several hundred milliseconds in published case studies — sometimes more, depending on what was blocking the original fetch.Don't
loading="lazy"the hero image. I see this mistake constantly because someone read "always lazy-load images" on a generic web-perf post and applied it everywhere. Lazy on the hero means the browser waits until layout is computed before requesting it, which is exactly the opposite of what you want for LCP.
For everything below the fold — product cards, secondary images, anything that needs a scroll to be seen — loading="lazy" is correct, and the same srcset pattern still applies.
The hero image fix alone is usually worth 1-2 seconds on a poorly-optimized store. It is the single highest-ROI thing on this list.
CLS: the late-loaded review widget
CLS on Shopify is almost always one of two things: web fonts swapping in late and pushing text down, or a third-party widget (reviews, swatches, recommendations) injecting itself after the rest of the page has already painted.
Reviews are the worst offender. The big review apps have gotten better, but plenty of merchants still have a <div id="reviews-widget"></div> that loads via async JS, fetches reviews from an API, and then pops a 400px-tall block into the middle of the PDP about 1.5 seconds after first paint. That's a CLS catastrophe — sometimes 0.4 or higher, when the threshold for "good" is 0.1.
The fix is to reserve the space. Even if you don't know the exact height, a min-height is enough:
{% comment %} On the PDP template, above the reviews widget container {% endcomment %}
<div
id="reviews-mount"
style="min-height: 320px; contain: layout;"
data-product-id="{{ product.id }}"
>
{% comment %} The widget will hydrate into this. {% endcomment %}
</div>
contain: layout is underused. It tells the browser that whatever happens inside this element won't cause reflow outside of it, which is a useful hint when the widget injects content of unpredictable height. Combined with a min-height that's at least as tall as the average rendered widget, you eat the layout shift inside the container instead of pushing the rest of the page around.
The other CLS culprit is font-display: swap on a font that loads slowly. If you're using a custom font from a CDN, either preload it or switch to font-display: optional for non-critical text. Optional won't swap if the font hasn't arrived in time, which avoids the flash but means some users see the fallback font on first visit. That's almost always a better tradeoff than a CLS hit.
INP: the JS bundle problem
INP replaced FID in 2024 and it's a much harder metric to fake your way through. FID measured first input delay; INP measures the slowest interaction across the entire session. If your "Add to cart" button has a 400ms response time because some analytics script is blocking the main thread, INP catches it.
On Shopify, INP failures usually trace back to one of three things: a giant theme JS bundle that runs on every page, a tag manager firing 15 third-party scripts on page load, or a section that re-renders the entire DOM tree when a variant changes.
The variant-switcher one is fixable with the Shopify Section Rendering API, and it's underused. Most themes update variant info by scanning the DOM, swapping image src attributes, updating prices manually, and rebinding event listeners. That works, but it gets slow once you have variant images, swatches, and a dynamic "X people are viewing this" widget all reacting to the change.
The cleaner pattern: hit the section endpoint and replace the whole section's HTML with the server-rendered version.
async function updateVariantSection(variantId, sectionId) {
const url = new URL(window.location.href);
url.searchParams.set('variant', variantId);
url.searchParams.set('section_id', sectionId);
const response = await fetch(url.toString(), {
headers: { 'Accept': 'text/html' }
});
if (!response.ok) return;
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const newSection = doc.querySelector(`#shopify-section-${sectionId}`);
const oldSection = document.querySelector(`#shopify-section-${sectionId}`);
if (newSection && oldSection) {
oldSection.replaceWith(newSection);
}
}
You hand off the rendering to Shopify's edge, which is fast, and you avoid running a bunch of client-side template logic that was the actual source of the INP issue. The first time I rewrote a "related products" carousel this way, the INP on that PDP came down from "needs improvement" range into "good" — and the carousel itself didn't get any faster. I just stopped doing work on the main thread.
The other INP move worth mentioning is breaking up long tasks with scheduler.yield() where it's available, falling back to a setTimeout(fn, 0). If you have an init function that's doing 200ms of synchronous work on page load, that 200ms is going to show up in INP if the user clicks anything during it. Yielding lets the browser process input in between.
The prefetch tactic — what it actually does
Once the page itself is fast, the next bottleneck is the navigation between pages. That's where <link rel="prefetch"> earns its keep on Shopify, because the typical session involves 3-5 product page views, and each one is a fresh round-trip.
The idea is simple: when a user hovers over a product link (or starts a touch on mobile), there's a delay of around 100-300ms before they actually click. That window is enough to fetch the next page's HTML in the background. By the time they actually click, the page is already in the browser cache and the navigation feels instant.
const prefetched = new Set();
function prefetch(href) {
if (prefetched.has(href)) return;
prefetched.add(href);
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = href;
link.as = 'document';
document.head.appendChild(link);
}
function shouldPrefetch(href) {
// Only same-origin, only product/collection routes,
// skip if user has data-saver on or is on slow connection
try {
const url = new URL(href, window.location.origin);
if (url.origin !== window.location.origin) return false;
if (!/^\/(products|collections)\//.test(url.pathname)) return false;
} catch { return false; }
const conn = navigator.connection;
if (conn?.saveData) return false;
if (conn?.effectiveType && /2g|slow-2g/.test(conn.effectiveType)) return false;
return true;
}
document.addEventListener('mouseover', (e) => {
const link = e.target.closest('a[href]');
if (link && shouldPrefetch(link.href)) prefetch(link.href);
}, { passive: true });
document.addEventListener('touchstart', (e) => {
const link = e.target.closest('a[href]');
if (link && shouldPrefetch(link.href)) prefetch(link.href);
}, { passive: true });
There are three things to be careful about:
Bots and crawlers. Googlebot doesn't hover, but plenty of crawlers fire synthetic mouseover events, and you don't want to waste merchant bandwidth prefetching pages for a scraper. Gating on
navigator.connectionfilters most of them out, but the more aggressive filter is to require an actual user gesture earlier in the session before enabling prefetch at all.Route gating. Don't prefetch the cart, checkout, or account pages. They're personalized, often uncacheable, and prefetching them wastes the round-trip. Restrict to product and collection routes where the response is cacheable.
The hover trigger doesn't fire on mobile. That's why you also bind to
touchstart— it gives you a similar window between the touch and the click, usually around 80-150ms, which is enough to start a fetch.
The upgrade path: Speculation Rules
<link rel="prefetch"> is the universally-supported floor. The ceiling is the Speculation Rules API, which Chrome 108+ and Edge 108+ support. Instead of just downloading the next page's HTML, Speculation Rules can fully prerender the destination — execute the JS, build the DOM, run client-side hydration — all in a hidden tab. When the user clicks, the prerendered page swaps in instantly with zero round-trip. It's the closest thing the web has to instant navigation.
const rules = {
prerender: [{
source: 'document',
where: {
and: [
{ href_matches: '/products/*' },
{ not: { href_matches: '/cart*' } },
{ not: { href_matches: '/checkout*' } },
{ not: { href_matches: '/account*' } }
]
},
eagerness: 'conservative'
}]
};
const script = document.createElement('script');
script.type = 'speculationrules';
script.textContent = JSON.stringify(rules);
document.head.appendChild(script);
The browser handles eviction, throttling, memory limits, and bot filtering — you just declare the rules. Eagerness conservative waits for hover/touch (similar to the manual prefetch above); moderate triggers a bit earlier; eager is for high-confidence next-page predictions. On a typical PDP, conservative is the right default.
For non-Chrome browsers — Safari mobile is the big one — fall back to the manual <link rel="prefetch"> approach. A real-world implementation runs both: Speculation Rules where supported, manual prefetch as the floor. That covers ~95% of e-commerce traffic.
The cumulative effect is that subsequent navigations on the store feel instant. Not "fast" — instant. It's the difference between a native app feeling and a web feeling. Whether it shows up in your CWV numbers depends on whether the prefetched page becomes the navigation that's measured (it sometimes does, via the Navigation Timing API reporting on bfcache and prefetch-served loads), but even when it doesn't, the perceived speed is meaningfully different.
What I learned shipping this
A few things that surprised me when I started seeing real merchant data:
The biggest CWV problems on most stores are introduced by apps the merchant installed, not by the theme. A store can have a beautifully optimized base theme and still score 30 on Performance because they've got six apps injecting scripts into the head, two of which are render-blocking. The merchant has no idea, because none of those apps say "this will tank your LCP" in their listing.
Speed wins compound. The merchants who fix LCP and then stop usually see a small lift. The ones who fix LCP, then CLS, then INP, then add prefetch end up with a store that feels different to use, and that's where the real conversion gains show up. Each individual fix moves the needle a little; the combination changes the user's perception of the brand.
And the prefetch tactic in particular is one of those things that's hard to A/B test cleanly, because the metric you're trying to improve (perceived navigation speed) doesn't show up in standard analytics. The signals I've seen — pages-per-session creeping up, bounce rate dropping on entry pages — are circumstantial but consistent.
Disclosure: I built and ship a Shopify app called Prefetch that bundles the scanner (PageSpeed Insights against the merchant's own storefront, surfaced in the admin dashboard) and the prefetch logic (Speculation Rules where supported, manual <link rel="prefetch"> as the floor). I mention it once, here, for transparency — none of the fixes above require an app, and the rest of this article works whether you install it or not.
The Liquid pattern, the CLS reservation, the Section Rendering API call — all of that is core Shopify, and any developer can ship it on a Friday afternoon. If you do nothing else after reading this, fix your hero image. It's the highest-ROI hour of work most stores have on the table.










