Cutting PDP LCP From 4s to 1.6s on Shopify Without Going Headless
A teardown of how we shaved seconds off product detail page LCP on a Shopify Plus store — using Liquid, the asset pipeline, and a few uncomfortable trade-offs. No Hydrogen required.

A client came to us last quarter with a Shopify Plus store doing solid revenue but a PDP LCP hovering around 4 seconds on mobile. The CRO team was convinced they needed to go headless. They didn't — and after eight weeks of unglamorous work, the same theme is now hitting LCP in the 1.5–1.7s range on a mid-tier Android over 4G.
This is the breakdown of what actually moved the needle, what we tried and abandoned, and where Liquid still bites you in 2026.
Why we didn't go headless
The pitch for Hydrogen or a custom Remix storefront is real: you get full control over the render path, edge caching you can reason about, and a JavaScript bundle that isn't shaped by a decade of theme conventions. But the cost is real too — a parallel checkout integration story, app ecosystem friction, and an ops surface that didn't exist before.
For this client, the math didn't work. They had:
- ~120 SKUs, low catalog churn
- A working Shopify theme with merchandiser-edited sections
- Three apps tied into the PDP (reviews, bundles, a size guide)
- No appetite to rebuild checkout
The LCP problem looked like a performance problem, not an architecture problem. So we treated it that way.
Rule of thumb we use internally: if your bottleneck is render path and asset weight, fix the theme. If your bottleneck is data fetching, state, or render strategy across many routes, then headless starts paying for itself.
Measuring before touching anything
The first week was just instrumentation. We pulled field data from Chrome UX Report and ran lab tests on three representative PDPs using WebPageTest with a Moto G-class profile on a throttled 4G connection. We also added a small RUM script that posted Web Vitals to our own endpoint, segmented by template and device class.
What we found:
- LCP element was always the main product image
- TTFB was ~600–800ms (acceptable, not great)
- ~1.4MB of JavaScript was being parsed before LCP fired
- Three render-blocking stylesheets, two from apps
- The hero image was being served at 2x the display size on mobile
That's a boring list, and that's the point. Most underperforming Shopify PDPs are boring problems stacked on top of each other.
The LCP element rule we keep forgetting
The LCP element on a PDP is almost always the main product image. That means everything you do should be in service of getting that one image painted as fast as possible. Every script, font, and CSS rule that delays it is the enemy. Once you accept that framing, prioritisation gets easy.
The image pipeline rewrite
Shopify's CDN is good, but the default theme code doesn't always use it well. Here's what the original theme was doing:
<img
src="{{ product.featured_image | img_url: '1200x' }}"
alt="{{ product.title }}"
loading="lazy"
/>
Three things wrong: img_url is deprecated in favour of image_url, there's no srcset so mobile downloads a desktop-sized image, and loading="lazy" on the LCP image is actively harmful because it defers the most important paint.
The replacement:
<img
src="{{ product.featured_image | image_url: width: 800 }}"
srcset="
{{ product.featured_image | image_url: width: 400 }} 400w,
{{ product.featured_image | image_url: width: 800 }} 800w,
{{ product.featured_image | image_url: width: 1200 }} 1200w,
{{ product.featured_image | image_url: width: 1600 }} 1600w
"
sizes="(min-width: 768px) 50vw, 100vw"
width="{{ product.featured_image.width }}"
height="{{ product.featured_image.height }}"
alt="{{ product.featured_image.alt | default: product.title | escape }}"
fetchpriority="high"
decoding="async"
/>
Key points: explicit width/height to reserve space and kill CLS, fetchpriority="high" to tell the browser this is the one, and sizes that matches the actual layout. Shopify's CDN handles AVIF/WebP negotiation automatically when you use image_url, so we didn't need to do anything else for format.
This one change alone took ~900ms off LCP on mobile.
Killing render-blocking work
The theme was loading a single 280KB CSS file in the head. About 40KB of that was PDP-critical. We extracted the above-the-fold styles into an inline <style> block in theme.liquid and deferred the rest:
<style>{% render 'critical-css' %}</style>
<link
rel="preload"
href="{{ 'theme.css' | asset_url }}"
as="style"
onload="this.onload=null;this.rel='stylesheet'"
/>
<noscript><link rel="stylesheet" href="{{ 'theme.css' | asset_url }}"></noscript>
This is an old pattern, but it works. The critical-css snippet is hand-maintained — about 6KB — and we have a CI check that flags PRs that change PDP layout without touching it.
The app script problem
Two of the three PDP apps were injecting scripts via Shopify's app embeds. One of them — a popular reviews app — was loading 380KB of JS synchronously and blocking the parser. We did three things:
- Moved the review widget below the fold and lazy-mounted it with an IntersectionObserver
- Replaced the synchronous app embed with a deferred custom loader
- Asked the app vendor (politely) why they weren't shipping a modern loader; the answer was "on the roadmap"
If a vendor's script is the single biggest thing standing between you and a good LCP, you have leverage to either defer it or replace the app. We've moved clients off otherwise-fine apps for exactly this reason.
TTFB and the Liquid trap
Shopify's edge is fast, but Liquid rendering on uncached pages can be slow if your theme is doing too much. We found one section that was iterating over all variants of all related products to build a colour swatch grid — about 4,000 iterations per render. That alone added ~120ms to server response time on cache misses.
The fix was to precompute the swatch data in a metafield via a scheduled job. Liquid then just reads the metafield. TTFB on cache misses dropped by ~100ms, and the Liquid render became boring again.
A few principles we now apply to every Shopify theme audit:
- Avoid nested loops over
collections,products, orvariantsin templates - Use metafields to cache expensive lookups
- Prefer
section_urland section rendering for dynamic blocks instead of refetching pages - Watch out for
all_productsaccess — it's slower than people think
What we tried and dropped
Not everything worked. A few things we tried that didn't earn their keep:
- Service worker for asset caching. Marginal gains on repeat visits, but added a debugging surface that wasn't worth it for a store with mostly first-time mobile traffic.
- Preloading the second image in the gallery. Sounded smart, hurt LCP because of bandwidth contention on slow connections.
- Self-hosting fonts instead of using Shopify's font picker. Saved ~40ms but broke the merchandiser workflow. We reverted.
- Aggressive Liquid fragment caching with a custom app. Worked, but the complexity-to-benefit ratio was bad for this catalogue size.
The numbers, with caveats
In our testing on a mid-tier Android over throttled 4G, PDP LCP went from roughly 3.8–4.2s to 1.5–1.7s. CLS went from 0.12 to under 0.02. Conversion rate on mobile lifted in the mid single digits over the following month — though we'd be lying if we said it was purely the speed work, because the client also tightened up their PDP copy in the same window.
Field data from CrUX caught up about six weeks later and confirmed the lab numbers were holding for real users.
Where we'd start
If you're staring at a slow Shopify PDP and someone is whispering "headless" in your ear, do this first:
- Measure with field data, not just Lighthouse. Lighthouse lies about Shopify storefronts in both directions.
- Identify the LCP element. If it's the product image, your job is image pipeline plus script discipline.
- Audit every app embed. Defer or remove anything that blocks the parser.
- Inline critical CSS, defer the rest. It's an old trick because it still works.
- Profile your Liquid. Nested loops and uncached metafield lookups are usually hiding in collection and product templates.
Going headless is a valid move when your problems are architectural. When they're not, you can usually claw back two seconds of LCP with a focused theme engagement and keep the rest of the platform working for you. If you want a second pair of eyes on a slow storefront, our e-commerce team does this kind of audit regularly, and we've written more about the trade-offs we keep running into.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading
Cart Abandonment Webhooks Lie: Building a Recovery Pipeline That Actually Attributes Revenue
Shopify's abandoned checkout webhook is noisy, late, and bad at attribution. Here's the event pipeline we build instead so recovery emails, SMS, and WhatsApp don't double-count or miss revenue.

Shopify Markets vs a Multi-Store Setup: How We Pick for Cross-Border Brands
Shopify Markets looks like the obvious answer for selling abroad — until tax, content, and payment realities hit. Here's how we decide between Markets, multiple stores, or a hybrid for cross-border brands.

Search on Shopify: When to Ditch the Native Search and What to Replace It With
Native Shopify search works until it doesn't. Here's how to spot the breaking point, pick a replacement (Algolia, Typesense, Meilisearch, or Shopify's own Search & Discovery), and wire it in without nuking your theme.
