Largest Contentful Paint: What It Measures and How to Improve It

Performance

Users do not experience page load as a binary event. They experience it as a progression: first the page response, then something visible, then the main content. Largest Contentful Paint (LCP) tries to capture the moment users feel the page is loaded by measuring when the largest element in the viewport finishes rendering.

A good LCP score is under 2.5 seconds. Over 4 seconds is poor. Between 2.5 and 4 is needs improvement — a range that most sites with render-blocking resources or unoptimized images fall into.

What Elements Count

LCP tracks specific element types:

  • <img> elements
  • <image> elements inside <svg>
  • <video> elements (the poster image)
  • Background images applied with CSS url()
  • Block-level text elements (paragraphs, headings)

The browser continuously updates the LCP candidate as larger elements become visible. When the user scrolls or interacts, tracking stops and the most recent candidate is the final LCP element.

On most marketing pages, the LCP element is the hero image. On article pages, it is often the largest heading or the first paragraph block.

The LCP Breakdown

Chrome DevTools and the Performance panel decompose LCP into four sub-phases:

  1. Time to First Byte (TTFB): How long until the first byte of the HTML document arrives
  2. Resource load delay: The gap between TTFB and when the browser begins downloading the LCP resource
  3. Resource load time: How long the resource (image) takes to download
  4. Element render delay: The gap between the resource finishing and the element being painted

Each sub-phase has a different root cause and different fix.

Fix 1: Eliminate Render-Blocking Resources

If your <head> contains synchronous <script> tags or non-preloaded CSS, the browser cannot render anything until they finish loading. Every millisecond spent downloading a font or a third-party analytics library delays LCP.

<!-- ❌ Render-blocking -->
<script src="/analytics.js"></script>
<link rel="stylesheet" href="/non-critical.css" />

<!-- ✅ Non-blocking -->
<script src="/analytics.js" defer></script>
<link rel="stylesheet" href="/non-critical.css" media="print" onload="this.media='all'" />

Fix 2: Preload the LCP Image

When the LCP element is an image in the first viewport, the browser discovers it by parsing the HTML. If the image is in a CSS background or a JavaScript-rendered component, the browser discovers it even later. <link rel="preload"> tells the browser to start downloading it immediately after the HTML is parsed:

<link
  rel="preload"
  as="image"
  href="/hero.webp"
  fetchpriority="high"
/>

The fetchpriority="high" attribute elevates this resource above other images in the download queue. This is especially important on pages that load multiple images — without it, the hero image competes with below-the-fold images.

Fix 3: Serve Modern Image Formats

WebP is 25–35% smaller than JPEG at equivalent quality. AVIF is 20–50% smaller than WebP in many cases. Both are supported in all modern browsers.

<picture>
  <source srcset="/hero.avif" type="image/avif" />
  <source srcset="/hero.webp" type="image/webp" />
  <img src="/hero.jpg" alt="Hero" width="1200" height="600" />
</picture>

Combining format optimization with width and height attributes prevents layout shifts and eliminates an intrinsic size calculation pass for the browser.

Fix 4: Reduce TTFB

LCP cannot be better than TTFB. If the server takes 1.2 seconds to respond, LCP starts at 1.2 seconds before rendering has even begun.

Options to reduce TTFB:

  • Move origin server closer to users (edge computing/CDN)
  • Cache rendered HTML at the CDN layer
  • Use streaming SSR to send HTML before all data is fetched
  • Optimize server-side data queries

A 200ms TTFB is good. Over 800ms is poor and dominates LCP.

Fix 5: Avoid Client-Side LCP Element Rendering

If the LCP element is rendered by JavaScript (a React component that fetches data before mounting the image), it cannot paint until:

  1. HTML is parsed
  2. JavaScript bundle downloads
  3. JavaScript executes
  4. React renders the component
  5. Image is discovered and downloaded
  6. Image is decoded and painted

This chain can easily take 3–4 seconds on an average mobile connection. Moving the LCP element to server-rendered HTML removes steps 2–5.

Measuring LCP

The PerformanceObserver API exposes LCP timing:

new PerformanceObserver((list) => {
  const entries = list.getEntries()
  const last = entries[entries.length - 1]
  console.log('LCP:', last.startTime, last.element)
}).observe({ type: 'largest-contentful-paint', buffered: true })

Google's web-vitals library wraps this cleanly and handles edge cases:

import { onLCP } from 'web-vitals'
onLCP(({ value, attribution }) => {
  console.log(value, attribution.lcpEntry.element)
})

The attribution object in newer versions tells you exactly which element caused the LCP and which sub-phase was the longest.

Conclusion

LCP is a user-centric metric that approximates a real feeling: "has the main content loaded?" Improving it requires addressing the full chain from server response to image paint. The highest-impact changes are typically: preloading the LCP image with fetchpriority="high", serving it in WebP or AVIF, and ensuring it is server-rendered or included in the initial HTML. Together these three changes can often move an LCP from the "needs improvement" band to "good" without changing anything about the application's architecture.