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.
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
// 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
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:
<img src="/hero.jpg" alt="" width="1280" height="720" fetchpriority="high">
<img data-src="/below-1.jpg" alt="" width="640" height="360" loading="lazy">
Verification Steps
- Confirm which element is being measured by logging
entry.elementand highlighting it:entry.element.style.outline = '3px solid red'. - Confirm the value is the last candidate by logging every entry with its
startTimeand 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()beforedisconnect(). 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.
Related
- PerformanceObserver buffered Entries Explained — why a late observer reports a perfect page
- Tracking Layout Shifts with PerformanceObserver — the other metric lazy loading commonly damages
- Building a Lazy Image Loader with IntersectionObserver — the loader that needs the hero exclusion
↑ Back to PerformanceObserver & Rendering Metrics