Most storefronts we inherit have a green Lighthouse score and a red Search Console report. That gap is the whole story: Lighthouse runs one simulated load on a fast machine, while ranking and revenue are decided by field data from real phones on real networks. Below is how we actually move LCP, INP and CLS on product pages, and how we prove it moved.
What you are being measured on
The three Core Web Vitals have fixed thresholds, evaluated at the 75th percentile of real users over a rolling 28-day window:
- LCP (Largest Contentful Paint): good under 2.5s, poor over 4.0s
- INP (Interaction to Next Paint): good under 200ms, poor over 500ms
- CLS (Cumulative Layout Shift): good under 0.1, poor over 0.25
Two consequences people miss. First, the 28-day window means a fix you ship today shows up in CrUX gradually over the next month, so judge the fix on your own RUM data, not on the public dataset. Second, p75 means your worst quarter of traffic sets the number. A catalog that is fast on desktop and 4.1s on mid-range Android will fail, because Android is usually more than 25% of sessions.
LCP on a product detail page
On a PDP the LCP element is the hero product image roughly nine times out of ten. The instinct is to compress it harder. That is usually the wrong lever.
Break LCP into its parts before touching anything: TTFB, resource load delay, resource load time, render delay. If TTFB is 900ms you cannot reach 2.5s LCP by shaving 40KB off a JPEG. Fix the server response first, then the image.
The most common real defect is request chain depth. The browser cannot start the hero image until it has parsed the HTML, and if the image URL is only known after a client component hydrates and fetches, you have added two round trips before the download even starts. Render the hero server-side with a known URL.
Then get the sizing right:
import Image from "next/image";
import { Suspense } from "react";
export default async function ProductPage({
params,
}: {
params: Promise<{ sku: string }>;
}) {
const { sku } = await params;
const product = await getProduct(sku); // cached, ISR-backed
return (
<main className="grid gap-8 md:grid-cols-2">
<Image
src={product.heroUrl}
alt={product.name}
width={1200}
height={1200}
priority
sizes="(min-width: 768px) 42vw, 100vw"
className="w-full h-auto"
/>
<div>
<h1>{product.name}</h1>
{/* Price and stock are volatile: stream them, don't block the page */}
<Suspense fallback={<PriceSkeleton />}>
<LivePrice sku={sku} />
</Suspense>
</div>
</main>
);
}
A wrong sizes is the most expensive one-line bug in ecommerce frontends. Without it the browser assumes 100vw and pulls a 1600px-wide file into a 380px slot. On one furniture catalog that single attribute cut the hero transfer from 412KB to 78KB and moved mobile LCP from 3.1s to 2.0s on a throttled 4G profile.
The rest of the image checklist:
- Serve AVIF with a WebP fallback, quality 65 to 75. Above 80 you are paying bytes nobody can see.
priority(which setsfetchpriority="high"and skips lazy loading) on the hero only. Marking six images priority means none of them is prioritized.preconnectto the image CDN origin if it differs from your own.- Never put the hero inside a carousel that initializes in JavaScript. Render slide one as plain markup and enhance afterwards.
Fonts
Fonts hurt twice: they delay text paint and they shift layout when the real face swaps in.
Self-host through next/font so the file comes from your origin with a stable hash and no extra DNS lookup. Prefer display: swap for body copy and consider optional for decorative headings you would rather drop than wait for. Subset to the ranges you ship, and be disciplined about weights: two weights covers most storefronts, while five adds 120KB for a difference nobody can articulate.
For the swap shift, next/font computes fallback metrics with size-adjust and ascent-override. Keep that on. It reliably takes the font-swap contribution to CLS to zero.
CLS is a discipline problem
Layout shift is almost never mysterious. The offenders repeat across every project:
- A promo bar injected after hydration, pushing the entire page down
- A cookie banner that reflows rather than overlays
- Lazy-loaded review widgets and recommendation rails with no reserved height
- Images without dimensions or
aspect-ratio - Web font swap without metric overrides
The rule we enforce in review: anything that can appear later must have its space reserved now. Give the promo bar a fixed height even when empty. Give the reviews rail a min-height matching its loaded state. Render banners as fixed-position overlays.
Remember CLS accumulates across the whole session, including after interaction. Opening a filter drawer that reflows the grid counts against you.
INP is your own JavaScript
INP replaced FID, and it is far less forgiving because it measures the full path from input to the next paint, at the worst interaction in the session. Long tasks on the main thread are the cause. Concretely:
- Hydration cost. A PDP that ships 380KB of client JavaScript will block the thread during hydration, and a tap in that window records a 600ms INP. Push
"use client"down to leaves. The gallery needs interactivity; the spec table does not. - Third-party tags. A tag manager loading six vendors is usually the single largest main-thread contributor.
- Handlers doing real work. Filtering 900 products inside an
onChangewill miss the frame. Wrap the state update inuseTransitionso the input stays responsive, or move the filtering server-side behind a URL param. - Avoid
inputhandlers that fire a network call per keystroke. Debounce at 200ms minimum.
Budgeting third parties
Load everything non-essential through next/script at afterInteractive, or lazyOnload for chat and support widgets. Then enforce a hard rule with the client: no new tag ships without removing one. Partytown can move some analytics into a worker, but it is a partial answer and it breaks any script that touches the DOM directly. The only method that settles arguments is measurement. Disable one vendor at a time on a preview deployment and record the delta in total blocking time. On a recent audit, three of six tags accounted for 240ms of the 310ms we removed.
Rendering strategy
Product and category pages should be statically rendered with revalidation, not dynamic. A fully dynamic PDP puts your database in the critical path of every crawl and every cold visit. Use ISR for the page shell, then stream the parts that genuinely change: price, stock, and personalized recommendations. revalidateTag on the catalog webhook keeps the static copy honest within seconds of a merchandising change.
Measure on real traffic
Ship the web-vitals attribution build and report to your own endpoint:
import {
onCLS,
onINP,
onLCP,
type MetricWithAttribution,
} from "web-vitals/attribution";
function report(metric: MetricWithAttribution) {
const body = JSON.stringify({
name: metric.name,
value: Math.round(metric.value),
rating: metric.rating,
// attribution names the offending element, not just the number
attribution: metric.attribution,
template: document.body.dataset.template ?? "unknown",
path: location.pathname,
});
navigator.sendBeacon("/api/vitals", body);
}
onLCP(report);
onINP(report);
onCLS(report);
Segment by template, device class and country. Aggregate numbers hide the failure: one client sat at 2.4s LCP overall while their highest-converting category template was at 3.6s. Add a bundle-size budget to CI so a regression is caught in the pull request rather than in next month's CrUX update.
How we work
On builds and maintenance retainers we set a performance budget during architecture, not after launch: a per-route JavaScript ceiling, an LCP target for the PDP and category templates, and a hard cap on third-party tags. The budget runs as a required check in CI, and RUM reporting ships with the first release so there is field data from day one. Clients get a monthly report segmented by template and device, with the specific element named for every regression, so the conversation is about a fix rather than a score.