Core Web Vitals on a Headless Shopify Store: Real Numbers
Every agency claims "faster performance." Here are actual Lighthouse runs, bundle analyses, and field data from headless Shopify stores compared against Dawn template baselines. Numbers, not adjectives.
The Baseline: Dawn Theme on Shopify
Before I show you headless numbers, you need to see what you are comparing against. Dawn is Shopify's reference theme. It is the best-performing template they offer. If your store uses a third-party theme, your numbers are almost certainly worse than these.
I ran Lighthouse on a Dawn-themed store with a standard product catalog. 200 products, 12 collections, 6 Shopify apps installed (reviews, email popup, upsell, chat, analytics, back-in-stock). This is a typical store. Not bloated. Not stripped down. Average.
Lighthouse Report - Dawn Theme (Mobile, Throttled)
=====================================================
Performance Score: 38
LCP (Largest Contentful Paint): 4.8s
FID (First Input Delay): 210ms
CLS (Cumulative Layout Shift): 0.18
TTFB (Time to First Byte): 1.2s
TBT (Total Blocking Time): 890ms
FCP (First Contentful Paint): 2.9s
Speed Index: 5.1s
Transfer Size Breakdown:
HTML: 42 KB
CSS: 187 KB (theme: 89KB, apps: 98KB)
JavaScript: 1,240 KB (theme: 218KB, Shopify core: 380KB, apps: 642KB)
Fonts: 124 KB
Images: 1,890 KB (hero: 680KB, product grid: 1,210KB)
Other: 94 KB
TOTAL: 3,577 KB
A performance score of 38. On Shopify's own reference theme. With a normal number of apps.
That 38 is not an outlier. I have run Lighthouse on over 40 Dawn-themed stores. The median score is 41. The best I have seen is 62, on a store with zero apps installed. The worst was 19, on a store with 14 apps.
The problem is not Dawn. Dawn is actually well-built for a Liquid theme. The problem is the architecture. Shopify serves every page from their origin servers. Every request goes through their CDN, their Liquid rendering engine, and their asset pipeline. Then every installed app injects its own JavaScript, CSS, and tracking pixels. You cannot control any of this.
The Headless Build: Same Store, Different Architecture
We migrated this store to a headless frontend. Next.js on Vercel. Same products, same images (optimized), same functionality. The apps were replaced with native implementations or removed entirely.
Lighthouse Report - Headless Build (Mobile, Throttled)
========================================================
Performance Score: 94
LCP (Largest Contentful Paint): 1.1s
FID (First Input Delay): 12ms
CLS (Cumulative Layout Shift): 0.01
TTFB (Time to First Byte): 48ms
TBT (Total Blocking Time): 120ms
FCP (First Contentful Paint): 0.6s
Speed Index: 1.4s
Transfer Size Breakdown:
HTML: 28 KB (pre-rendered, includes critical CSS)
CSS: 34 KB (scoped, no unused rules)
JavaScript: 142 KB (framework: 87KB, app: 55KB)
Fonts: 18 KB (subset, woff2 only)
Images: 189 KB (AVIF hero, lazy-loaded grid)
Other: 12 KB
TOTAL: 423 KB
Performance score: 94. Total transfer: 423 KB versus 3,577 KB. That is an 88% reduction in page weight.
Let me break down what changed and why each number improved.
TTFB: 1.2s to 48ms
Time to First Byte is how long the browser waits before it receives any data from the server. On Shopify, this is constrained by their infrastructure. The request hits their CDN, gets routed to an origin server, Liquid renders the page, and the response comes back. That takes over a second.
On a headless build deployed to Vercel, the product page is a static HTML file sitting on a CDN edge node. When the browser requests it, the nearest edge node responds with the cached file. No server rendering. No database queries. No template compilation.
TTFB comparison by hosting:
Shopify (Liquid rendering): 800-1,500ms
Vercel Edge (static/ISR): 30-80ms
Cloudflare Pages (static): 15-50ms
Self-hosted Node.js (SSR): 200-600ms
The 48ms TTFB means the browser starts receiving HTML before the Dawn store has even finished its TLS handshake. Everything downstream benefits. FCP is faster because the browser has content sooner. LCP is faster because images start loading sooner. The entire waterfall shifts left.
LCP: 4.8s to 1.1s
Largest Contentful Paint measures when the biggest visible element finishes rendering. On a product page, this is almost always the hero product image.
On the Dawn store, the hero image was a 680 KB JPEG. It loaded after the browser parsed the HTML, downloaded and executed the render-blocking CSS, downloaded the image, and decoded it. Total: 4.8 seconds on a throttled mobile connection.
On the headless build, we made four changes:
1. Format: JPEG to AVIF. Shopify's CDN supports WebP but not AVIF. We route images through Vercel's image optimization pipeline, which serves AVIF to supported browsers and WebP as fallback.
// next.config.js
module.exports = {
images: {
formats: ['image/avif', 'image/webp'],
domains: ['cdn.shopify.com'],
deviceSizes: [640, 750, 828, 1080, 1200],
imageSizes: [16, 32, 48, 64, 96, 128, 256],
},
};
The same hero image went from 680 KB (JPEG) to 89 KB (AVIF). Same visual quality. 87% smaller. The browser downloads it in a fraction of the time.
2. Sizing: Responsive srcset. The Dawn template served the same image size to every device. A 1200px wide image on a 375px wide phone. The headless build serves appropriately sized images using Next.js Image component.
<Image
src={product.images[0].url}
alt={product.images[0].altText || product.title}
width={800}
height={600}
sizes="(max-width: 768px) 100vw, 50vw"
priority
placeholder="blur"
blurDataURL={product.images[0].blurHash}
/>
The priority prop tells Next.js to preload this image. It adds a <link rel="preload"> tag in the head, so the browser starts downloading the image before it even parses the body HTML.
3. Placeholder: blur-up. While the image loads, the user sees a blurred placeholder generated from a tiny (10px wide) version of the image. This is not a gray box. It is a recognizable preview of the actual image. The perceived load time drops significantly even though the actual load time is the same.
4. No render-blocking resources. On Dawn, three CSS files must download before the browser renders anything. On the headless build, critical CSS is inlined in the HTML. Non-critical CSS loads asynchronously. The browser starts rendering immediately.
CLS: 0.18 to 0.01
Cumulative Layout Shift measures how much the page jumps around while loading. A score above 0.1 fails Core Web Vitals. Dawn scored 0.18. Two things caused this.
First, images without explicit dimensions. When the browser encounters an image tag without width and height, it does not know how much space to reserve. The image loads, and everything below it shifts down. This happened with the hero image and every product grid image.
Second, app-injected elements. The reviews widget loaded asynchronously and pushed the Add to Cart button down by 200 pixels. The email popup shifted the entire viewport. The chat bubble appeared and caused a minor shift in the bottom right corner.
The fix for images is trivial but requires discipline:
// Every image MUST have explicit width and height
// Next.js Image component enforces this
<Image
src={imageUrl}
width={400} // Actual rendered width
height={300} // Actual rendered height
alt="Product"
/>
// For dynamic aspect ratios, use fill mode with a sized container
<div style={{ position: 'relative', width: '100%', aspectRatio: '4/3' }}>
<Image
src={imageUrl}
fill
sizes="(max-width: 768px) 50vw, 25vw"
alt="Product"
style={{ objectFit: 'cover' }}
/>
</div>
The fix for dynamic content is reserving space before the content loads:
// Reviews section: reserve minimum height
.reviews-section {
min-height: 200px; /* Prevents shift when reviews load */
}
// Chat widget: fixed position, does not affect layout
.chat-widget {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 50;
}
Getting CLS to 0.01 is not complicated. It requires knowing where every dynamic element will appear before it loads, and reserving that space in CSS. On a template, you cannot do this because app-injected elements are outside your control.
TBT: 890ms to 120ms
Total Blocking Time measures how long the main thread is blocked by JavaScript execution. This is the metric that makes or breaks interactivity. If TBT is high, buttons feel unresponsive. Scrolling stutters. The page feels slow even if it looks loaded.
On Dawn, the main thread was blocked by 890ms of JavaScript execution. Here is where that time went:
Main Thread Blocking Breakdown (Dawn):
Shopify analytics bundle: 180ms
Theme JavaScript (Dawn): 95ms
Reviews app: 142ms
Email popup app: 88ms
Upsell app: 124ms
Chat widget: 167ms
Back-in-stock app: 56ms
Google Tag Manager: 38ms
TOTAL: 890ms
Six Shopify apps contributed 577ms of blocking time. The merchant installed these apps because they needed the functionality. Each app is individually reasonable. Together, they destroy performance.
On the headless build, we replaced app functionality with lightweight native implementations:
Main Thread Blocking Breakdown (Headless):
Next.js hydration: 42ms
React component rendering: 28ms
Cart state initialization: 15ms
Analytics (deferred): 22ms
Reviews (lazy loaded): 13ms
TOTAL: 120ms
The key strategies:
Code splitting. The chat widget, search overlay, and reviews section are loaded only when the user interacts with them. They are not in the initial bundle.
// Dynamic import: chat widget loads on first click
const ChatWidget = dynamic(() => import('../components/ChatWidget'), {
loading: () => <ChatPlaceholder />,
ssr: false,
});
// Dynamic import: search overlay loads when search is opened
const SearchOverlay = dynamic(() => import('../components/SearchOverlay'), {
ssr: false,
});
Deferred analytics. Google Analytics and tracking scripts load after the page is interactive. They use requestIdleCallback to avoid blocking user interactions.
// Load analytics after page is interactive
useEffect(() => {
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
loadAnalytics();
});
} else {
setTimeout(loadAnalytics, 2000);
}
}, []);
No third-party app scripts. Every feature that was previously a Shopify app is now a native React component. Reviews are fetched from an API and rendered in our code. Email capture is a custom form. Back-in-stock notifications are a simple webhook registration. We control every byte of JavaScript on the page.
Bundle Analysis: Where the Bytes Go
Here is the JavaScript bundle breakdown for the headless build, generated by @next/bundle-analyzer:
JavaScript Bundle Analysis (gzipped):
========================================
Framework:
next/react runtime: 42.1 KB
react-dom: 38.7 KB
next/router: 6.2 KB
Subtotal: 87.0 KB
Application:
Product page components: 12.4 KB
Cart logic (Shopify Buy): 18.2 KB
SEO/meta components: 3.1 KB
Image optimization helpers: 2.8 KB
Utility functions: 4.6 KB
Subtotal: 41.1 KB
Lazy-loaded (not in initial bundle):
Chat widget: 34.2 KB
Search overlay: 28.7 KB
Reviews section: 15.3 KB
Email capture modal: 8.9 KB
Product configurator: 22.1 KB
Subtotal: 109.2 KB (loaded on demand)
Initial bundle total: 128.1 KB (gzipped)
Full bundle total: 237.3 KB (gzipped)
Compare this to the Dawn store: 1,240 KB of JavaScript loaded on every page. The headless build loads 128 KB initially and another 109 KB on demand, only when the user needs it.
The framework overhead (87 KB for React + Next.js) is a fixed cost. It does not grow as you add pages. The application code (41 KB) grows slowly because we aggressively code-split. Each page only loads the components it needs.
Font Loading: 124 KB to 18 KB
Fonts are a silent performance killer. The Dawn store loaded four font weights of a custom typeface: regular, medium, semibold, and bold. Each weight was a full character set WOFF2 file at approximately 30 KB. Total: 124 KB just for fonts.
Our approach:
// 1. Subset fonts to only characters used on the site
// Before: 30 KB per weight (full Latin Extended + Cyrillic)
// After: 4.5 KB per weight (Basic Latin + common punctuation)
// 2. Load only two weights: regular (400) and bold (700)
// Skip medium and semibold. The visual difference is negligible.
// 3. Use font-display: swap to prevent FOIT
@font-face {
font-family: 'Inter';
font-weight: 400;
font-display: swap;
src: url('/fonts/inter-400-subset.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC;
}
@font-face {
font-family: 'Inter';
font-weight: 700;
font-display: swap;
src: url('/fonts/inter-700-subset.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC;
}
// 4. Preload the regular weight (used on first paint)
<link
rel="preload"
href="/fonts/inter-400-subset.woff2"
as="font"
type="font/woff2"
crossorigin
/>
Two weights at 4.5 KB each, plus one preloaded. Total font load: 9 KB transferred, 18 KB uncompressed. The font-display: swap ensures the browser shows system fonts immediately and swaps to custom fonts when they load. No invisible text. No flash of unstyled text lasting more than 100ms.
If you want to eliminate custom font loading entirely, use the system font stack:
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI',
Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
}
Zero KB font load. Instant text rendering. The trade-off is brand consistency across platforms. For most e-commerce stores, this trade-off is worth it. Your customers are not evaluating your font choice. They are evaluating your products.
Image Optimization Deep Dive
Images accounted for 1,890 KB of the Dawn store's 3,577 KB total. More than half. This is the single biggest optimization opportunity on any e-commerce site.
Image Optimization Results:
============================
Dawn Headless Savings
Hero image: 680 KB 89 KB 87%
Product grid (6): 1,210 KB 62 KB 95%
Logo/icons: 48 KB 3 KB 94%
TOTAL: 1,938 KB 154 KB 92%
Techniques used:
Format: JPEG/PNG -> AVIF (WebP fallback)
Sizing: 1200px -> responsive srcset (375-1200px)
Loading: eager -> hero: eager, rest: lazy
Quality: 100% -> 75% (visually identical)
Decode: sync -> async (decoding="async")
The product grid images dropped from 1,210 KB to 62 KB. That is not a typo. The Dawn store served six 1200x1200 JPEG images at full quality. The headless build serves AVIF thumbnails at 400px wide, lazy-loaded, at 75% quality. On a mobile screen, they look identical. The file size is 95% smaller.
AVIF support is at approximately 90% of browsers. For the remaining 10%, we fall back to WebP (97% support) and then JPEG. The Next.js Image component handles this automatically via the Accept header.
Field Data vs Lab Data
Lighthouse scores are lab data. They tell you how the page performs in a simulated environment. Field data from Chrome User Experience Report (CrUX) tells you how real users actually experience the page.
Here is the field data comparison after three months of headless deployment:
CrUX Field Data (75th percentile, mobile):
=============================================
Dawn Headless Threshold
LCP: 5.2s 1.4s < 2.5s (good)
FID: 186ms 14ms < 100ms (good)
CLS: 0.22 0.02 < 0.1 (good)
INP: 312ms 68ms < 200ms (good)
Core Web Vitals: FAIL PASS
Google PageSpeed Insights origin summary:
Dawn: 23% of visits had good CWV
Headless: 91% of visits had good CWV
The field data is worse than lab data for Dawn (LCP 5.2s vs 4.8s) because real users are on slower connections and older devices than Lighthouse simulates. The field data is slightly worse for the headless build too (1.4s vs 1.1s), but it passes every threshold comfortably.
The INP (Interaction to Next Paint) metric is worth calling out. It replaced FID in March 2024 as the official responsiveness metric. It measures the worst interaction latency during the entire page visit, not just the first input. Dawn's 312ms means users experienced noticeable lag when clicking buttons or opening dropdowns. The headless build's 68ms means interactions feel instant.
What This Means for Rankings and Revenue
Google confirmed that Core Web Vitals are a ranking signal. The effect is not dramatic. Content relevance still dominates. But at the margins, performance is a tiebreaker. Two pages with similar content and authority: the faster one ranks higher.
More importantly, performance directly affects conversion rate. Google's research shows that as page load time goes from 1s to 3s, bounce probability increases by 32%. From 1s to 5s, it increases by 90%.
Observed conversion impact (same store, same traffic):
Dawn (LCP 5.2s): 0.4% conversion rate
Headless (LCP 1.4s): 1.1% conversion rate
Bounce rate change: -34%
Pages per session: +2.1
Session duration: +45 seconds
Add-to-cart rate: +68%
I am not claiming the entire conversion improvement is from performance alone. The headless build also improved the UX, added lead capture, and redesigned the product pages. But performance is the foundation. You cannot have a great user experience on a slow page. The page has to load before the experience can begin.
How to Measure Your Own Store
Run these three checks on your current Shopify store. It takes five minutes.
1. Lighthouse. Open Chrome DevTools, go to the Lighthouse tab, select Mobile and Performance, and run the audit. Look at the Performance score and the six metrics. If your score is below 50, you have a significant performance problem.
2. PageSpeed Insights. Go to pagespeed.web.dev and enter your product page URL. This shows both lab data (Lighthouse) and field data (CrUX). The field data section tells you if you pass or fail Core Web Vitals based on real user data.
3. Bundle analysis. Open DevTools, go to the Network tab, reload the page with cache disabled, and sort by size. Add up the JavaScript. If it exceeds 500 KB, you are carrying dead weight. Check each script's domain. Third-party app scripts from apps.shopify.com are the usual suspects.
If your Lighthouse performance score is above 70 and you pass CrUX Core Web Vitals, your template is performing well. Keep it. Performance alone does not justify going headless.
If your score is below 50 and you fail CrUX, the template is measurably hurting your business. Every day you delay is revenue lost to slow pages and high bounce rates. We can show you exactly how much.