A field guide to Core Web Vitals on a real e-commerce rebuild. What actually moved Largest Contentful Paint, what looked useful but did not, and how we kept the gains after launch.
The legacy Vue 2 storefront scored a GTmetrix D. Typical mobile numbers:
The business goal was not a Lighthouse screenshot. Faster first paint and faster interaction tracked with a 22% lift in mobile conversion and a lower bounce rate. Performance work was product work.
LCP is not "the page feels slow." It is a specific element: usually a hero image, a heading, or a poster frame.
new PerformanceObserver((list) => {
const last = list.getEntries().at(-1);
console.log(last.element, last.startTime, last.url);
}).observe({ type: 'largest-contentful-paint', buffered: true });
On product pages the LCP element was the hero image. On category pages it was a heading plus a banner. Homepage LCP was a carousel slide that loaded after JS. That last one was a design bug wearing a performance costume.
Fix: first slide is in the HTML. The carousel script can wait.
A skeleton that hydrates into content still counts against LCP. The browser cannot paint text that does not exist yet.
// Slow pattern: empty shell, data on the client
export function ProductHero() {
const { data } = useProduct();
if (!data) return <HeroSkeleton />;
return <h1>{data.name}</h1>;
}
// Fast pattern: name is in the first HTML
export default async function ProductPage({ params }) {
const product = await getProduct(params.slug);
return <h1>{product.name}</h1>;
}
SSR/SSG for catalog and content pages was the largest LCP lever. Client fetching stayed for cart counts and personalized recs, which are not LCP.
priority on the LCP image onlysizes so the optimizer does not send a desktop file to a phone<Image
src={product.hero}
alt={product.name}
width={1200}
height={800}
sizes="(max-width: 640px) 100vw, 600px"
priority
/>
We stopped loading three family weights from a third-party CDN. One variable font, display: swap, subset to Latin. Invisible text is still a delay users feel even when LCP looks fine.
LCP can look healthy while INP is terrible. Checkout was the proof.
What we removed from the critical path:
const Reviews = dynamic(() => import('./Reviews'), {
loading: () => <ReviewsPlaceholder />,
ssr: false,
});
Route-based splitting plus hydration only on interactive islands dropped main-thread time enough that Fully Loaded Time fell from 8.4s to 1.3s in lab tests, with page size down 85% and requests down 60%.
Lab scores lie if you only test a warm desktop cache. We tracked:
Budgets lived next to the PR:
{
"lcp": "1.8s",
"tbt": "200ms",
"jsKb": 180
}
If a feature needed more JS, it paid for itself with code-splitting or it waited.
The work that stuck was boring: render the LCP element in HTML, send a correctly sized image, and keep JavaScript off the first paint. That is how 3.1s became 1.2s.