Fixing LCP on Headless Product Pages
Product templates are where decoupled architectures lose the most LCP time, because the largest element on the page is an image whose URL is only known after a commerce API call resolves.
When to Use This Approach
Work through this page when:
- Your field p75 for LCP on the product URL group is above 2500 ms while other templates are comfortably green.
- The lab score is good but the field score is not, which usually means the fast path only exists for warm caches.
- A migration to a decoupled front end regressed LCP without changing the page design at all.
Attributing the Metric Before Changing Anything
LCP is a composite of four sequential stages, and each has a completely different fix. Optimising the image when the origin is slow, or caching harder when the image is simply too large, both waste a release cycle.
The pattern above is the signature of a decoupled build. The document arrives quickly, but it contains no reference to the product image, so the browser sits idle until the client resolves the API response and inserts the <img>.
Implementation Steps
The six steps below run in order, but they split cleanly into two halves: the first two are measurement and must not be skipped, while the last four are the actual changes and only one of them will usually matter for your site.
Step 1: Identify the real LCP element
// rum-lcp.js — ship this before optimising anything
import { onLCP } from 'web-vitals/attribution';
onLCP((metric) => {
navigator.sendBeacon('/rum/lcp', JSON.stringify({
value: metric.value,
element: metric.attribution.element,
url: new URL(location.href).pathname,
}));
});
Validation: Group the beacons by path template. If the element is a text node on most product views, the image is arriving after the text and is not yet a candidate.
Step 2: Split the metric into sub-parts
onLCP(({ value, attribution: a }) => {
const parts = {
ttfb: a.timeToFirstByte,
delay: a.resourceLoadDelay,
load: a.resourceLoadDuration,
render: a.elementRenderDelay,
};
navigator.sendBeacon('/rum/lcp-parts', JSON.stringify({ value, ...parts }));
});
Validation: Compute the median of each sub-part across a week. Exactly one will typically account for more than 40% of the total — that is the only stage worth working on this sprint.
Step 3: Make the hero discoverable in the document
The fix for a dominant load delay is to put the image reference in the HTML the server sends, either by prerendering the route or by emitting a preload header at the edge from the product cache.
// app/product/[handle]/page.jsx
export const revalidate = 900;
export async function generateStaticParams() {
const handles = await commerce.products.list({ fields: ['handle'], limit: 5000 });
return handles.map((h) => ({ handle: h.handle }));
}
export default async function Product({ params }) {
const product = await commerce.products.get(params.handle);
return (
<main>
<img
src={product.image.url}
alt={product.image.alt}
width={product.image.width}
height={product.image.height}
fetchPriority="high"
decoding="async"
/>
<h1>{product.title}</h1>
<PriceAndStock handle={params.handle} />
</main>
);
}
Validation: curl -s https://example.com/product/blue-widget | grep -c '<img' returns at least one before any JavaScript executes.
Step 4: Emit the preload from the edge for dynamic routes
Products that genuinely cannot be prerendered — personalised bundles, region-specific catalogues — can still get an early hint from a small edge lookup.
// middleware.js — the edge knows the hero URL from a lightweight KV lookup
export async function middleware(request) {
const res = NextResponse.next();
const handle = new URL(request.url).pathname.split('/').pop();
const hero = await HERO_KV.get(handle);
if (hero) {
res.headers.append('Link', `<${hero}>; rel=preload; as=image; fetchpriority=high`);
}
return res;
}
Validation: curl -sI https://example.com/product/blue-widget | grep -i '^link:' shows exactly one image preload.
Step 5: Right-size the image itself
A discoverable image that is four times larger than its rendered box still loses seconds on a mobile connection.
<img
src="/cdn/blue-widget-800.avif"
srcset="/cdn/blue-widget-400.avif 400w,
/cdn/blue-widget-800.avif 800w,
/cdn/blue-widget-1200.avif 1200w"
sizes="(max-width: 768px) 100vw, 640px"
width="800" height="800" alt="Blue widget, front view"
fetchpriority="high">
Validation: In DevTools, the intrinsic size of the selected candidate should be no more than about twice the rendered box on a 2× display.
Step 6: Confirm in the field, not the lab
curl -s "https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=$CRUX_KEY" \
-H 'Content-Type: application/json' \
-d '{"origin":"https://example.com","formFactor":"PHONE"}' \
| jq '.record.metrics.largest_contentful_paint.percentiles.p75'
Validation: Compare against the pre-change baseline after a full 28-day collection window. Lab numbers move immediately; field numbers are the ones that count.
SEO Impact Summary
| Signal | What improves | What breaks if misconfigured |
|---|---|---|
| Page experience | Product URLs move into the good LCP bucket, strengthening a ranking tie-breaker | Preloading several images starves the one that defines LCP |
| Crawl efficiency | Prerendered product routes answer from cache, raising sustainable crawl rate | Prerendering stale prices creates a content-accuracy problem instead |
| Conversion | Faster first paint on the highest-intent template reduces bounce before interaction | An oversized hero on mobile burns the visitor’s data budget |
| Index coverage | Faster origin responses free crawl capacity for deeper catalogue pages | Static generation of every variant explodes the build time and route count |
Measurable signals to watch:
- Field p75 LCP for the product URL group, tracked separately from the site aggregate.
- The share of LCP attributed to
resourceLoadDelay, which should collapse once the hero is in the document. - Origin response time for product routes, since a regression there re-inflates the whole metric.
Edge Cases and Gotchas
The commerce data on a product page divides neatly by volatility, and that division is what makes a static LCP path possible on a page whose price changes hourly.
Price and stock do not belong on the LCP path The temptation is to make the whole product page dynamic because the price changes. Split it: the shell, the image, and the copy are static, while price and availability stream in or fetch client-side. Neither is ever the largest element, so neither affects LCP.
Variant switching can reset the metric on soft navigation Selecting a different colour often swaps the hero image. Browsers do not recalculate LCP for soft navigations, but real users perceive the delay anyway — preload the likely next variant on hover rather than leaving it to chance.
A CDN image transform can be the load delay If the first request for a given size triggers an on-the-fly transform, the earliest visitors pay a multi-second penalty that never appears in lab testing. Warm the transform for every hero size during the build, or accept that the p75 will reflect cold transforms. This interacts directly with edge caching behavior for SEO.
Static generation at catalogue scale needs a budget Prerendering 200,000 products is a build-time problem, not a rendering one. Prerender the top decile by traffic and let the rest regenerate incrementally — the approach described in configuring Next.js ISR for optimal crawl budget.
Frequently Asked Questions
Why is my LCP element a text block rather than the product image? The browser picks the largest element painted in the viewport, and an image that has not been discovered yet is not a candidate. If the title block paints first, it becomes the LCP element — a symptom of late image discovery, not a reason to stop optimising the image.
Does preloading the hero image always help? Only when resource load delay dominates. If time to first byte dominates, the browser has not received the HTML yet and the preload is irrelevant; preloading several images can even hurt by competing for bandwidth. Attribute first, then preload exactly one.
Should product pages be statically generated? For the shell and the primary media, yes — those change less often than teams assume. Price and stock can arrive separately, since neither is ever the largest contentful element. The split gives you a static LCP path with fresh commerce data.
How long before a fix shows up in Search Console? Field data is a trailing 28-day window, so a fix deployed today only fully appears about four weeks later. Watch your own real-user monitoring for the immediate signal and treat the Core Web Vitals report as confirmation rather than as the feedback loop.
Part of: Core Web Vitals in Headless Front Ends
Related
- Eliminating CLS From CMS-Driven Media — the layout-stability half of the same image pipeline
- Edge Caching Behavior for SEO — cut the time to first byte that sits underneath every LCP measurement
- Diagnosing Render-Blocking JavaScript in Headless — find the render delay that survives after the image is fixed