LCP Measurement & Optimization

Largest Contentful Paint (LCP) is the Core Web Vitals metric that most directly tracks what a user perceives as “the page loaded”: the moment the largest piece of content in the viewport finishes rendering. As established in Core Web Vitals & Performance Metrics Fundamentals, a single synthetic Lighthouse run cannot represent the full range of real devices, networks, and cache states your users actually hit — so LCP has to be measured in the field, aggregated at p75, and treated as a moving target across deploys. This reference covers what qualifies as the LCP element, how to capture it reliably with PerformanceObserver, how to read the resulting field data, the optimization levers that move it, and the failure modes that quietly corrupt the number.

LCP is a load-phase metric: it is determined entirely by the critical rendering path between the navigation request and the paint of the largest element. That makes it tractable. Unlike interaction-latency metrics, you are not chasing unpredictable user behavior — you are chasing a deterministic chain of “request the document → parse → discover the hero resource → fetch it → decode → paint.” Every optimization in this page targets one link in that chain.

LCP critical path and threshold bands A timeline showing TTFB, resource discovery, fetch and decode leading to the LCP paint, with Good, Needs Improvement, and Poor threshold bands beneath. LCP critical path Navigation TTFB Discover parse + preload Fetch + decode hero image LCP paint render finalized Field thresholds (p75) Good ≤ 2.5s Needs Improvement ≤ 4.0s Poor > 4.0s Capture lifecycle observe buffered candidates browser emits multiple, keep the last finalize on visibilitychange first input or hidden stops LCP
LCP is fixed by the critical path; capture buffered candidates and finalize when the page is hidden or the user first interacts. See FCP & TTFB Analysis for the server-side links of this chain.

What qualifies as the LCP element

The browser does not pick a fixed element up front. It maintains a running candidate and re-evaluates on every paint, emitting a new largest-contentful-paint entry each time a larger content element renders. Candidate elements are limited to a specific set: <img> (and <image> inside <svg>), <video> poster images, elements with a CSS background-image loaded via url(), and block-level text nodes. Whitespace, gradients, and elements painted with low opacity do not qualify.

Two subtleties trip people up. First, LCP only grows during the load phase and stops at the first user interaction (scroll, click, keypress). An element that paints after the user starts scrolling will never become the LCP, even if it is larger. Second, the reported size is the visible size clamped to the viewport — an oversized hero image counts only for the pixels actually on screen, and images served far larger than their rendered box are penalized in the candidate’s intrinsic-size heuristic, not rewarded.

Because the candidate keeps changing, every capture implementation must hold the latest entry rather than the first, and must finalize the value at a defined lifecycle boundary. That finalization rule is the single most common source of wrong LCP numbers in homegrown RUM.

Threshold configuration

LCP thresholds are evaluated at p75 across page loads — the value 75% of your real users experience or better. Use p75 everywhere as the headline; a mean hides the slow tail that Google ranks on. Map each band to a concrete engineering response so the metric drives work rather than dashboards.

Threshold LCP at p75 Engineering action
Good ≤ 2.5 s Hold the line; add a CI regression gate so the budget cannot drift upward unnoticed
Needs Improvement ≤ 4.0 s Audit the critical path: render-blocking CSS/JS, late hero discovery, missing fetchpriority
Poor > 4.0 s Treat as a P1; profile TTFB, server render of the hero, and resource priority together

These bands are the field-data targets. A passing Lighthouse score with a failing CrUX p75 means your lab profile does not match your users — segment the field data before trusting any synthetic number.

Measurement implementation

For production capture, prefer the web-vitals npm library, which normalizes PerformanceObserver and buffered entries across browsers and handles the lifecycle edge cases for you. The hand-rolled observer below exists to show exactly what that library does under the hood, and to give you a dependency-free fallback. It is the implementation the dedicated walkthrough, Measure LCP with the PerformanceObserver API, expands on step by step.

The observer registers with buffered: true so entries that fired before your script ran are replayed. It keeps the last candidate, and finalizes exactly once — on visibilitychange → hidden, on pagehide, or on first input — disconnecting to prevent any further candidate from mutating an already-reported value.

const LCP_METRIC_NAME = 'lcp';
let lcpValue = null;
let lcpCandidate = null;
let isFinalized = false;

const observer = new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const lastEntry = entries[entries.length - 1];

  // The browser emits a new entry each time a larger element renders.
  // Always keep the most recent candidate.
  lcpCandidate = lastEntry;
  // renderTime is 0 for cross-origin images without Timing-Allow-Origin;
  // fall back to loadTime so the value is never NaN.
  lcpValue = lastEntry.renderTime || lastEntry.loadTime;
});

observer.observe({ type: 'largest-contentful-paint', buffered: true });

const sendLCP = () => {
  if (isFinalized || lcpCandidate === null || lcpValue === null) return;
  isFinalized = true;
  observer.disconnect();

  const nav = performance.getEntriesByType('navigation')[0];
  const payload = {
    metric: LCP_METRIC_NAME,
    value: Math.round(lcpValue),
    element: lcpCandidate.element?.tagName || 'unknown',
    elementId: lcpCandidate.element?.id || '',
    url: lcpCandidate.url || '',
    size: lcpCandidate.size || 0,
    navigationType: nav?.type || 'navigate',
    ts: Date.now()
  };

  // sendBeacon survives the unload the browser is already performing.
  navigator.sendBeacon('/rum/ingest', JSON.stringify(payload));
};

// LCP also stops growing at the first interaction — finalize then too.
addEventListener('keydown', sendLCP, { once: true, capture: true });
addEventListener('pointerdown', sendLCP, { once: true, capture: true });

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') sendLCP();
});
window.addEventListener('pagehide', sendLCP);

The beacon goes to a self-hosted ingestion endpoint that validates and persists each event; from there the value is aggregated at p75 with whatever sampling strategy your traffic volume requires. Round the value before sending — sub-millisecond precision is noise and inflates payload size.

Why visibilitychange, not load

The load event fires when subresources finish, which can be long before or long after the LCP paint, and never fires at all for users who navigate away mid-load. visibilitychange → hidden and pagehide are the only events guaranteed to fire when a session ends, including bfcache evictions and tab switches on mobile. Finalizing there is what makes the metric represent the experience the user actually had rather than the experience of users who waited patiently for load.

Step-by-step debugging workflow

When a regression appears in field data, work the chain from symptom to root cause in order — skipping straight to optimization wastes a deploy cycle.

  1. Identify the candidate. Pull the LCP element selector from RUM (the element/elementId you beaconed) or read it live in the DevTools Performance panel’s LCP marker. Confirm it is the element you expect; a stray banner or cookie notice becoming the LCP is itself the bug.
  2. Trace the waterfall. Map every request between navigation and the candidate’s paint. Look for the hero resource being discovered late (deep in the DOM, injected by JS, or behind a render-blocking stylesheet).
  3. Correlate overlaps. Check for long tasks and render-blocking scripts executing during the LCP window; if the candidate is text, a blocking web font swap can be the delay. Where layout instability appears in the same window, reconcile it against CLS Reduction Strategies so you are not fixing one metric at the expense of another.
  4. Validate in the lab. Reproduce the exact device class and network throttle from the field segment — not a default profile — in Lighthouse or WebPageTest. If you cannot reproduce it, the regression is segment-specific and you mis-read step 2.
  5. Deploy the fix. Apply one targeted change (resource hint, priority, server render) so the field delta is attributable.
  6. Monitor the delta. Track p75 over 7–14 days against the control. Confirm the CI gate now passes and that no sibling metric regressed.

Field-data analysis patterns

Aggregate LCP is nearly useless on its own; the signal lives in the segments. A “Needs Improvement” p75 across the whole site usually decomposes into a “Good” desktop cohort and a “Poor” low-end Android cohort, and only the segmented view tells you where to spend engineering time.

Segment What it exposes Action when it diverges
Device class CPU-bound decode and main-thread contention on low-tier mobile Ship smaller/responsive hero variants, defer non-critical JS, server-render the hero
Network type (4G/3G/slow) RTT and bandwidth stretching fetch + decode Preconnect to the image origin, compress to AVIF/WebP, raise priority
Geography CDN edge proximity and DNS latency feeding into TTFB Add regional edge caching; reduce origin round-trips
Navigation type Cold load vs bfcache restore vs SPA route change Exclude bfcache restores; treat SPA transitions separately (see failure modes)

Watch specifically for field-vs-lab divergence: when your synthetic LCP is Good but CrUX p75 is not, the lab profile is too fast. The fix is to re-throttle the lab to match the p75 segment, then debug there. The relationship between LCP and its upstream component, server response time, is detailed in FCP & TTFB Analysis; a slow TTFB sets a floor under LCP that no front-end optimization can break through.

Optimization strategies

Every LCP win is one of: make the hero discoverable sooner, fetch it faster, decode it faster, or stop something from blocking it. The highest-leverage, lowest-risk change for most pages is correcting resource priority — the browser’s default priority for in-viewport images is not high until layout proves they are visible, which is often too late.

<!-- Open the connection to the image origin before it is needed -->
<link rel="preconnect" href="https://cdn.example.com" crossorigin>

<!-- Discover and prioritize the hero before the parser reaches it -->
<link rel="preload" as="image" href="/hero-1280.avif" fetchpriority="high"
      imagesrcset="/hero-640.avif 640w, /hero-1280.avif 1280w"
      imagesizes="100vw">

<!-- Mark the actual element high-priority so the browser does not deprioritize it -->
<img src="/hero-1280.avif" fetchpriority="high" width="1280" height="720"
     srcset="/hero-640.avif 640w, /hero-1280.avif 1280w" sizes="100vw"
     alt="Product hero">

<!-- Stop a stylesheet from blocking the first paint -->
<link rel="stylesheet" href="/non-critical.css" media="print" onload="this.media='all'">
<script src="/analytics.js" defer></script>

The concrete, measured impact of these two attributes is the subject of Optimizing LCP with fetchpriority & preload, which shows the before/after waterfall for each.

Technique Before After Typical p75 win
fetchpriority="high" on the hero <img> image queued behind early scripts image fetched in the first wave 200–600 ms
<link rel="preload" as="image"> for a CSS background hero discovered after CSSOM builds discovered during HTML parse 300–900 ms
Responsive srcset + AVIF/WebP one oversized JPEG for all devices right-sized, modern format per device 400 ms–1.5 s on mobile
Remove render-blocking CSS/JS from <head> paint waits on full CSSOM critical CSS inlined, rest deferred 300–800 ms
Server-side render the hero hero injected client-side after hydration hero in the initial HTML 500 ms–2 s

Crucially, do not lazy-load the LCP element. A loading="lazy" on the hero defers its fetch until layout confirms visibility, which is the opposite of what you want — reserve lazy loading for below-the-fold images only.

Failure modes and gotchas

  • SPA route changes restart nothing. largest-contentful-paint is emitted only for the initial hard navigation. A client-side route change does not produce a new LCP entry, so a framework router that swaps views will report the first page’s LCP forever — or nothing, if your observer disconnected. Either scope LCP strictly to hard navigations or instrument soft navigations separately; do not pretend a route transition is an LCP.
  • Lazy-loaded or JS-injected hero. If the largest element is added by a component after hydration, its paint may land after the first input and never register as the LCP, making the metric look good while the user stares at an empty hero. Server-render or eagerly load anything above the fold.
  • Cross-origin renderTime is zeroed. Without a Timing-Allow-Origin response header on the image, renderTime is 0 and you must fall back to loadTime — the code above does this. If you forget, every cross-origin hero reports an LCP near zero and silently passes.
  • Safari and PerformanceObserver gaps. Safari did not support the largest-contentful-paint entry type until recently and still lacks some attribution fields. Feature-detect with PerformanceObserver.supportedEntryTypes.includes('largest-contentful-paint') and accept that a slice of Safari traffic will simply be absent from LCP — do not impute it.
  • Background-tab suspension. A tab opened in the background never paints, so its LCP can be enormous or absent. Discard samples where the page was hidden before first paint (document.visibilityState === 'hidden' at script start) rather than letting them poison p75.

CI/CD integration

Gate LCP in two places. In pull-request CI, run Lighthouse-CI against a throttled profile that matches your slowest real cohort and fail the build if simulated LCP exceeds 2.5 s or regresses more than ~15% from the baseline:

npm install -g @lhci/cli
lhci autorun \
  --collect.settings.preset=desktop \
  --collect.url=https://staging.example.com/product/1 \
  --assert.assertions.largest-contentful-paint=error \
  --assert.assertions.largest-contentful-paint.maxNumericValue=2500

Lab gating catches gross regressions but cannot see real-network LCP. Pair it with a field gate: after each release, compare the new build’s p75 LCP against the prior release in your RUM store before promoting to 100% of traffic. Releasing behind a flag and reading the cohort delta turns LCP from a vanity dashboard into a release blocker that actually protects users.

FAQ

Is LCP measured in the lab or in the field?

Both, but only the field number ranks. Lighthouse gives a deterministic LCP for one device/network profile, which is useful for CI gating and reproduction. Search ranking and the thresholds in this page are evaluated against CrUX/RUM field data at p75 across your real users — always reconcile the two when they disagree.

Why does my LCP value change every time the page loads more content?

Because the browser re-evaluates the largest element on every paint and emits a new entry each time. Your capture code must keep the last candidate, not the first, and finalize on visibilitychange, pagehide, or first input. LCP also stops growing the moment the user interacts.

Should I ever lazy-load the LCP image?

No. loading="lazy" defers the fetch until layout proves the element is visible, which delays the very paint LCP measures. Lazy-load below-the-fold images only, and mark the hero fetchpriority="high".

Why is my cross-origin hero reporting an LCP near zero?

renderTime is zeroed for cross-origin resources that lack a Timing-Allow-Origin header. Add that header on the image response, or fall back to loadTime in your observer so the value is never falsely small.

Does an SPA route change generate a new LCP?

No. The largest-contentful-paint entry fires only for the initial hard navigation. Soft navigations produce no new LCP entry, so you must instrument route transitions as a separate custom metric rather than reusing the load-phase LCP.