Largest Contentful Paint is the one Core Web Vital that observer-driven code most often makes worse, because the same lazy loader that improves a page's byte count can defer the exact element the metric is measuring.

Problem / Scenario Context

A marketing page ships a full-width hero image and a well-built lazy image loader. Every image on the page, including the hero, carries data-src and waits for an intersection callback before fetching. Byte counts fall, the Lighthouse "defer offscreen images" audit goes green, and LCP gets noticeably worse.

The reason is straightforward once stated: the hero is not offscreen. Deferring it inserts a script round-trip — parse the bundle, run the callback, set src, start the request — in front of a fetch the browser's preload scanner would otherwise have started while the HTML was still streaming. The metric that measures "when did the main content appear" duly reports the delay.

Measuring this correctly is the first step to fixing it, and the measurement has two subtleties that catch people out: the callback fires repeatedly, and it stops firing at a moment that has nothing to do with page load finishing.

Mechanics Explanation

The browser maintains a running "largest contentful element so far". Each time a candidate paints that is larger than the previous one, it emits a largest-contentful-paint entry. A page whose text renders first and whose hero arrives 900 ms later produces two entries: one for the paragraph, one for the image.

The metric is the last entry emitted before the first user interaction. Two consequences follow:

  • Acting on the first entry measures the wrong element, almost always something small that painted early.
  • The metric is finalised by interaction, not by load. A reader who scrolls or clicks at 400 ms freezes LCP at whatever had painted by then — which is why synthetic tests that interact immediately report suspiciously good numbers.

The element field on the entry is what turns a number into an action. It is a live reference to the node that defined the candidate, so a report can name it.

Three Candidates, One MetricA page-load timeline with three largest-contentful-paint entries: a headline at 600 milliseconds, a paragraph block at 900, and the hero image at 2100. A first click at 2600 milliseconds finalises the metric. The reported value is the hero image entry, the last one before the interaction, and a note marks that acting on the first entry would have reported the headline instead.03sheadline600msparagraph block900mshero image2100ms — the metricfirst click — reporting stopsActing on entry #1 reports 600ms — wrong elementKeeping the last value reports 2100ms — the real experienceEvery larger candidate emits a new entry; only the final one counts

Comparison Table: Loading Strategy vs. LCP

Hero treatment Fetch starts Typical LCP effect
Plain <img src> in the HTML Preload scanner, during parse Baseline
<img src> + fetchpriority="high" Preload scanner, ahead of other requests Best
loading="lazy" on the hero After layout determines it is in view Worse by one layout pass
data-src + observer After the bundle parses and the callback runs Worst — adds a script round-trip
Observer, but hero excluded Preload scanner for the hero only Baseline, with the byte savings kept

The last row is the whole recommendation. Lazy loading is right for everything below the fold and wrong for the element that defines LCP, and "everything except the first one" is a rule a template can express.

Minimal Reproducible Example

TypeScript
// Reports the wrong number: acts on the first candidate
new PerformanceObserver((list) => {
  const first = list.getEntries()[0];
  report('lcp', first.startTime);         // the headline, not the hero
}).observe({ type: 'largest-contentful-paint', buffered: true });

Production-Safe Solution

TypeScript
interface LCPEntry extends PerformanceEntry { size: number; element?: Element; url?: string; }

let lcp = 0;
let lcpElement: Element | null = null;
let finalised = false;

const po = new PerformanceObserver((list) => {
  if (finalised) return;
  for (const raw of list.getEntries()) {
    const entry = raw as LCPEntry;
    lcp = entry.startTime;                 // keep overwriting — the last one wins
    lcpElement = entry.element ?? null;
  }
});
po.observe({ type: 'largest-contentful-paint', buffered: true });

// The metric is finalised by the first interaction, not by load
const finalise = () => {
  if (finalised) return;
  finalised = true;
  for (const raw of po.takeRecords()) {    // drain anything queued but undelivered
    lcp = (raw as LCPEntry).startTime;
    lcpElement = (raw as LCPEntry).element ?? null;
  }
  po.disconnect();
};
for (const type of ['keydown', 'click', 'pointerdown'] as const) {
  addEventListener(type, finalise, { once: true, capture: true });
}

addEventListener('visibilitychange', () => {
  if (document.visibilityState !== 'hidden') return;
  finalise();
  navigator.sendBeacon('/vitals', JSON.stringify({
    lcp: Math.round(lcp),
    // naming the element is what makes the number actionable
    lcpSelector: lcpElement ? describe(lcpElement) : null,
    path: location.pathname,
  }));
}, { once: true });

function describe(el: Element): string {
  const id = el.id ? `#${el.id}` : '';
  const cls = el.className && typeof el.className === 'string'
    ? `.${el.className.trim().split(/\s+/).slice(0, 2).join('.')}` : '';
  return `${el.tagName.toLowerCase()}${id}${cls}`;
}

And the corresponding change to the loader — exclude the hero rather than tuning the margin:

HTML
<img src="/hero.jpg" alt="" width="1280" height="720" fetchpriority="high">
<img data-src="/below-1.jpg" alt="" width="640" height="360" loading="lazy">

What Deferring the Hero Adds in Front of the FetchTwo request paths for the same hero image. In the direct path the preload scanner discovers the src during HTML parsing and the fetch starts immediately. In the deferred path the browser must first download and parse the bundle, run the observer callback, and only then set the source, so the fetch begins several hundred milliseconds later and the LCP entry moves with it.plain src — the scanner finds itHTML parsingfetch startst = 120msdata-src — four steps firstHTML parsingbundle downloadparse + executecallback sets srct = 900msThe bytes saved are real. They are simply not worth paying for on the one element the metric is defined by.

Which Element Types Can Be an LCP CandidateA list of element types the browser considers for Largest Contentful Paint: image elements, image inputs, video poster frames, elements with a CSS background image loaded via url, and block-level elements containing text. Elements excluded from consideration are listed alongside, including anything with zero opacity and anything positioned entirely outside the viewport.counts as a candidate<img> and <input type="image"><video> poster framebackground-image: url(…)block elements containing textexcludedopacity: 0entirely outside the viewportlow-entropy placeholdersa background image is not preload-scannableWhich is why a CSS background hero is a poor choice regardless of how it is loaded.

Verification Steps

  • Confirm which element is being measured by logging entry.element and highlighting it: entry.element.style.outline = '3px solid red'.
  • Confirm the value is the last candidate by logging every entry with its startTime and comparing the final one against your reported number.
  • Confirm the exclusion worked by checking the network panel: the hero request should start in the first wave, before the bundle finishes.
  • Confirm interaction finalisation by clicking immediately on one load and not at all on another; the two reported numbers should differ.
  • Check the field data, not just the lab run — a single cold load on a fast connection will not show the regression that a slow-connection percentile does.

Common Mistakes to Avoid

  • Reporting the first entry. It is almost never the element the reader was waiting for.
  • Lazy loading the hero. The single most common self-inflicted LCP regression, and the easiest to fix.
  • Forgetting buffered: true. A late observer reports nothing, which reads as a perfect score rather than as missing data — see PerformanceObserver buffered entries explained.
  • Skipping takeRecords() before disconnect(). A candidate delivered in the same task as the first click is otherwise dropped.
  • Assuming a CSS background image can be the LCP element. It can, but it is not discoverable by the preload scanner at all, which makes it a poor choice for a hero regardless of observers.

FAQ

Why does the LCP callback fire more than once?

The browser emits a new entry every time a larger contentful element paints, so a page whose hero arrives after its text produces several. The metric is the last entry before the first interaction, which is why the correct handling is to keep overwriting a variable rather than acting on the first thing you receive.

Does lazy loading the hero image really hurt LCP?

Yes, measurably. Deferring it puts a bundle download, a parse and a callback in front of a fetch that the preload scanner would otherwise have started during HTML parsing. Exclude the first in-view image from the loader and give it fetchpriority high instead.

When does the browser stop reporting LCP candidates?

At the first user interaction — a click, key press or pointer down — not at load. This means a page the reader interacts with immediately reports a lower LCP than the same page left alone, and synthetic tests that click early will under-report.

How do I find out which element defined LCP?

Read the element field on the entry. It is a live node reference, so you can outline it during debugging or serialise a short selector for a field beacon. Reporting the number without the element gives you a metric nobody can act on.


↑ Back to PerformanceObserver & Rendering Metrics