Engineering
Core Web Vitals for ecommerce: the three fixes that moved the needle
LCP 4.3s to 1.9s, INP 420ms to 140ms, CLS 0.28 to 0.02 — and a 9.4% lift in mobile conversion we actually measured.
The site: about 2.1 million sessions a month, 78% mobile, a headless storefront on Next.js in front of a commerce backend. The field data from CrUX, at the p75 mobile, when we started:
- LCP — 4.3s (needs improvement territory, badly)
- INP — 420ms (poor)
- CLS — 0.28 (poor)
Their Lighthouse score, in the office, on wifi, on a MacBook, was 89. This is the first thing to say.
Measure the field, not the lab
Lighthouse is a lab tool. It runs one page, once, on your machine, with a simulated throttle. Core Web Vitals is a field metric — what real users on real devices actually experienced, at the 75th percentile. A green Lighthouse score with poor field data is the most common situation we walk into, and it happens because your users are not you.
Before changing anything, instrument real user monitoring. The web-vitals library gives you attribution — not just "LCP was slow" but which element and which phase:
import { onLCP, onINP, onCLS } from "web-vitals/attribution";
const send = (metric) => {
navigator.sendBeacon("/rum", JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
// the part that actually tells you what to fix:
attribution: metric.attribution,
path: location.pathname,
conn: navigator.connection?.effectiveType,
}));
};
onLCP(send); onINP(send); onCLS(send);
Two days of this data told us more than three weeks of Lighthouse would have. It is what turned "the site is slow" into three specific bugs.
Fix 1 — LCP: the image was fine, the discovery chain was not
The LCP element on every product page was the hero product image. It was already a well-compressed 40 KB WebP. Compressing it further would have gained nothing. The problem was when the browser found out about it.
The chain was:
- HTML arrives.
- Browser parses, finds the JS bundle, downloads it (180 KB).
- React hydrates. The gallery is a client component.
- The gallery mounts and, only now, renders an
<img>tag. - Browser finally discovers the image URL and starts downloading it.
The image download did not begin until roughly 2.8 seconds in. The preload scanner — the browser's fastest, smartest optimisation — never saw it, because at HTML-parse time the image did not exist in the markup.
Three changes:
Render the LCP image in the server HTML. The first gallery frame is now a plain server-rendered <img>. The interactive carousel hydrates around it. The preload scanner finds the URL in the initial HTML response.
Mark it as high priority. Browsers guess at image priority and they guess wrong for below-the-fold-looking markup. Tell them:
<img
src="/p/sku-4821-800.avif"
srcset="/p/sku-4821-400.avif 400w, /p/sku-4821-800.avif 800w"
sizes="(max-width: 768px) 100vw, 50vw"
width="800" height="800"
fetchpriority="high"
decoding="async"
alt="..."
>
Fix the CDN. Their image CDN was configured to serve WebP to everything. Every current mobile browser accepts AVIF, which was ~30% smaller at the same visual quality on their photography. Content negotiation on Accept, and a Vary: Accept header so the CDN caches per-format.
One more thing that mattered: they had four preconnect hints. Preconnect is a strong hint and it is not free — each one occupies a connection slot. Three of theirs were to analytics domains, competing with the origin that was serving the LCP image. We cut it to one (the image CDN).
LCP: 4.3s → 1.9s. Not one byte of image compression involved.
Fix 2 — INP: a search box and a tag manager
INP measures the worst interaction latency in the session. RUM attribution pointed at two things.
The search box was re-rendering the entire page on every keystroke. A search term held in a context provider near the root meant that every character typed re-rendered the header, the mega-menu and the product grid underneath the dropdown. On a mid-range Android, each keystroke was a 180 ms task. The user types eight characters and the main thread is gone.
The fix is standard React hygiene, and it is the most common INP bug we find in ecommerce:
function SearchBox() {
const [term, setTerm] = useState("");
// The input stays instant; the expensive subtree renders at lower priority.
const deferred = useDeferredValue(term);
return (
<>
<input value={term} onChange={(e) => setTerm(e.target.value)} />
<Suggestions term={deferred} /> {/* memoised */}
</>
);
}
Keeping the search state local to the component, rather than in a global context that half the tree subscribes to, did most of the work. useDeferredValue did the rest.
A tag manager was executing on the main thread during interactions. Six third-party scripts loaded through GTM, one of which ran a 300 ms task on scroll. We could not delete the tags — marketing needs them, and pretending otherwise is how a performance project dies in a meeting. So we deferred them: nothing non-essential loads until after the first user interaction or 3 seconds of idle, whichever comes first. The conversion pixels — the ones that actually needed to fire on the page — stayed, and got moved to server-side tagging.
We also broke up the remaining long tasks. Anything over 50 ms yields:
async function processLongList(items) {
for (const chunk of chunked(items, 50)) {
render(chunk);
// Give the browser a chance to respond to the user.
await scheduler.yield();
}
}
INP: 420ms → 140ms.
Fix 3 — CLS: three causes, one of them a font
CLS was 0.28, which means the page visibly jumps while you are trying to tap something. Three contributors:
The promotional banner. Fetched client-side, injected at the top of the page, and it pushed everything down 90 px about 800 ms after paint. Fixed by reserving the space with min-height — and rendering the banner server-side, since it changes twice a week and does not need to be a client fetch at all.
Images without dimensions. Some. Not as many as you would expect. width and height attributes, or an aspect-ratio in CSS, always. This is the boring one everybody already knows and half of everybody still gets wrong.
The font. This was the biggest single contributor and it was invisible to everyone. Their brand font loaded with font-display: swap, so text first painted in the fallback and then reflowed when the webfont arrived — and the two fonts had noticeably different metrics, so every block of text changed height. Every product title, every price.
The fix is a metric-matched fallback. You override the fallback font's metrics so it occupies exactly the same space as the real font:
@font-face {
font-family: "Brand Fallback";
src: local("Arial");
size-adjust: 105.2%;
ascent-override: 92%;
descent-override: 24%;
line-gap-override: 0%;
}
body { font-family: "Brand", "Brand Fallback", sans-serif; }
Now the swap is invisible — the text does not move, it just changes shape. Next.js does this automatically with next/font, which is a genuinely good reason to use it. If you are hand-rolling, the numbers come from the font's own metrics; do not guess them.
CLS: 0.28 → 0.02.
Did it make money?
This is the question that matters, and the honest answer requires care, because we shipped other things in the same quarter and "conversion went up after we did performance work" is not evidence.
We ran a geo-split: the optimised build to half of India's states, the old one to the other half, for three weeks, on comparable traffic.
- Mobile conversion: +9.4% on the optimised cohort (statistically significant at their volume)
- Bounce rate on product pages: -11%
- Organic sessions: up 14% over the following three months — correlated, and we will not claim causation, because CWV is a genuine but light ranking factor and three other things changed in that window
The bounce-rate and conversion numbers are the real ones. The conversion lift was concentrated almost entirely in users on slow connections and cheap devices, which is exactly what you would predict, and is the strongest evidence that the work did what we said it did.
The order to do this in
- Instrument RUM with attribution. Two days. Do not skip to step 2.
- Find the actual LCP element on your top three templates. It is rarely what you assume.
- Fix the LCP discovery chain before you touch image compression.
- Find your worst interaction from field data. It is usually a search box, a filter, or a third-party script.
- Fix CLS last — it is the easiest and the most satisfying, which is exactly why teams do it first and then run out of budget.
Written by
PRS Admin
Building software at PRS India.