How We Cut LCP from 3.1s to 1.2s
How We Cut LCP from 3.1s to 1.2sSeptember 4, 2025

How We Cut LCP from 3.1s to 1.2s

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.

Table of Contents

  1. The Starting Point
  2. Find the Real LCP Element
  3. Server HTML Beats Client Skeletons
  4. Images and Fonts
  5. JavaScript That Blocks Interaction
  6. Measuring So You Do Not Regress
  7. What Did Not Help

The Starting Point {#start}

The legacy Vue 2 storefront scored a GTmetrix D. Typical mobile numbers:

  • LCP: 3.1s
  • Fully loaded: 8.4s
  • Page weight and request count both far above budget

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.

Find the Real LCP Element {#find}

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.

Server HTML Beats Client Skeletons {#html}

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.

Images and Fonts {#assets}

Hero images

  • Known width/height to stop layout shift
  • priority on the LCP image only
  • Correct sizes so the optimizer does not send a desktop file to a phone
  • No CSS background for the LCP image; backgrounds are harder to discover and optimize
<Image
  src={product.hero}
  alt={product.name}
  width={1200}
  height={800}
  sizes="(max-width: 640px) 100vw, 600px"
  priority
/>

Fonts

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.

JavaScript That Blocks Interaction {#js}

LCP can look healthy while INP is terrible. Checkout was the proof.

What we removed from the critical path:

  • Chat widget on every page
  • Reviews module on first paint
  • Analytics bundled into the main chunk
  • A mega-menu that computed on every route
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%.

Measuring So You Do Not Regress {#measure}

Lab scores lie if you only test a warm desktop cache. We tracked:

  • Lighthouse CI on a throttled mobile profile
  • GTmetrix as a weekly trend, not a trophy
  • RUM for LCP by route and device
  • Sentry for the JS errors that spike after a "perf" release

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.

What Did Not Help {#not}

  • Micro-optimizing Redux selectors before cutting the hero image
  • Adding a service worker before cache headers were correct
  • Prefetching every product link on a 200-item PLP
  • Chasing a 100 Lighthouse score on pages with required third-party checkout scripts

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.