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 four sub-parts of LCP and the fix each one implies A single horizontal bar split into four proportional segments — time to first byte, resource load delay, resource load duration, and element render delay — with the corresponding remedy labelled beneath each segment. One LCP measurement, four stages — fix the widest one TTFB resource load delay load duration render cache at the edge prerender + preload resize, modern format unblock CSS typical headless product page: discovery dominates The image cannot start downloading until the commerce API tells the page it exists

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.

Measurement phase then a single targeted remedy Two measurement steps feeding a decision point, which routes to one of three remedies — cache the origin, make the hero discoverable, or resize the image — before a shared field-verification step. identify element split into sub-parts widest? TTFB → cache at edge delay → prerender load → resize image verify in field

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.

Splitting product data by volatility to protect the LCP path A volatility axis from rarely changing to constantly changing, with product fields placed along it and a boundary marking which fields belong in the prerendered shell. changes yearly changes hourly prerender boundary hero image description specifications price stock level in the prerendered shell — defines LCP streamed after first paint Nothing to the right of the boundary is ever the largest contentful element

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