Why the LCP Element Changes Between Loads — and How to Make It Stable
This guide explains a source of persistent confusion in Measuring LCP with Chrome DevTools and the wider Core Web Vitals & Measurement workflow: you optimise the hero image, the lab LCP improves, and field data reports the LCP element is now a paragraph, a logo, or a cookie banner — on some loads but not others.
LCP is not a fixed element; it is the last entry in a stream of candidates. As the page renders, every time a larger image or text block paints, the browser emits a new largest-contentful-paint entry. The stream stops at the first user input or scroll. The final entry is the page's LCP. Anything that changes which elements paint, how large they render, or in what order they paint can change the winner — and with it, which optimisation actually moves the metric.
Rapid Diagnosis
- Log every candidate, not just the last. In the console:
new PerformanceObserver(l => l.getEntries().forEach(e => console.log(Math.round(e.startTime), e.size, e.element))).observe({type: 'largest-contentful-paint', buffered: true}). Reload several times with different throttling. - Compare viewports. Run the same page at 390px and 1350px wide. Mobile layouts often hide or shrink the hero, promoting a heading or a product grid image.
- Check field element distribution. If your RUM collects the LCP element selector (via
attribution.elementin theweb-vitalsattribution build), chart its distribution per template. More than one element with a meaningful share is the signal. - Watch for late, large paints. Cookie banners, interstitials, newsletter modals and carousels advancing to a larger slide all generate new candidates late in the load.
Root Cause Analysis
1. Viewport-dependent layout. The size used to rank candidates is the visible area in the viewport. A hero that is 1200×600 on desktop might be 390×195 on a phone — smaller than a heading that wraps across three lines at a large font size. Different devices legitimately have different LCP elements.
2. Paint order races. If the hero image loads quickly, it paints before the font finishes and wins outright. If the image is slow, the heading paints first as a candidate, then the image replaces it later. In both cases the image is LCP, but with different timing. If the image is very slow and the user scrolls first, the heading becomes the final LCP instead.
3. Late overlays and banners. A consent banner injected by a third-party script at 2.8s can be the largest text block painted on a small screen. It becomes the LCP and its timing — controlled by a vendor — defines your metric.
4. Hydration or client re-rendering. When a framework replaces server-rendered markup during hydration (a mismatch, or a component that re-mounts), the element is removed and re-added. Removed elements stop being candidates; the re-inserted one is a new candidate with a later timestamp.
5. Carousels and rotating content. A hero carousel that advances to a slide with a larger image, or swaps in a different image after a timer, generates a new candidate each time until input stops the stream.
Step-by-Step Resolution
1. Decide what the LCP element should be, per template and viewport
Write it down: "Product page, mobile: main product image. Article, mobile: H1." This intent turns a fluctuating metric into a test you can assert against.
// Lab assertion: the final LCP candidate must match the intended selector.
const intended = { '/product/': 'img.product-hero', '/blog/': 'h1.article-title' };
const lcp = await page.evaluate(() => new Promise((r) => new PerformanceObserver((l) => {
const e = l.getEntries().at(-1); r(e.element?.matches?.('img.product-hero, h1.article-title') ? 'ok' : e.element?.outerHTML.slice(0, 120));
}).observe({ type: 'largest-contentful-paint', buffered: true })));
// trade-off: lab assertions use one viewport and fast hardware. Run them at the
// smallest supported viewport, where element swaps are most likely.
Expected outcome: regressions that change the LCP element are caught in CI rather than discovered in field data a month later.
2. Make the intended element win early
Preload and prioritise the intended LCP image so it paints before competing text, and give it dimensions that keep it the largest element at mobile widths.
<link rel="preload" as="image" href="/img/hero-800.avif" fetchpriority="high"
imagesrcset="/img/hero-400.avif 400w, /img/hero-800.avif 800w" imagesizes="100vw">
<img src="/img/hero-800.avif" srcset="/img/hero-400.avif 400w, /img/hero-800.avif 800w"
sizes="100vw" width="800" height="600" fetchpriority="high" alt="">
<!-- trade-off: forcing an image to be LCP on mobile is only worth it if the
image is genuinely the main content. If the heading is what users read
first, let the heading be LCP and optimise for text instead. -->
Expected outcome: the LCP element distribution in RUM concentrates on one element per template, and timing variance shrinks.
3. Keep late overlays from becoming LCP
Server-render consent banners and announcement bars with a compact layout so they paint early and small, or render them in a way that does not out-size the main content on small screens.
.consent-banner { max-height: 30vh; font-size: 0.875rem; }
/* trade-off: shrinking the banner reduces its candidate size, but if it is
still the largest TEXT on a screen with only an image hero, it will not
matter — images and text compete on area. Check on the smallest viewport. */
Expected outcome: banners stop appearing in the field LCP element distribution.
4. Stop client re-rendering of the LCP element
Eliminate hydration mismatches on above-the-fold markup and avoid keying hero components on values that differ between server and client. Disable carousel autoplay until after the first user interaction.
// Carousel: do not advance until the user has interacted or the page is idle.
let allowAutoplay = false;
addEventListener('pointerdown', () => { allowAutoplay = true; }, { once: true });
requestIdleCallback(() => { setTimeout(() => { allowAutoplay = true; }, 5000); });
// trade-off: delaying autoplay changes the product behaviour slightly. Most
// teams find a 5s delay invisible to users, but confirm with the design owner.
Expected outcome: the LCP timestamp matches the first paint of the hero rather than a later repaint.
Verification
Re-run the candidate logger across five loads at mobile and desktop widths, with and without throttling. The final candidate should be the intended element on every run. In RUM, the element distribution for each template should be dominated by one selector, and the p75 LCP for that selector should match the overall p75 closely. Finally, compare LCP variance: a stable element typically halves the gap between p50 and p95, which makes regressions much easier to detect.
When an Unstable LCP Element Is Acceptable
Not every variation needs fixing. On a news homepage where the lead story changes hourly, the LCP element is meant to change — sometimes a photo, sometimes a headline. What matters is that each candidate type is fast. Group by element type (image vs text) rather than by selector, and set budgets for both. Similarly, desktop and mobile having different LCP elements is a natural consequence of responsive design; treat them as separate experiences with separate p75 values instead of forcing one element to win everywhere.
FAQ
Does a background image count as an LCP candidate?
Yes — CSS background-image on an element is a candidate, sized by the visible area of the element. It is usually a poor LCP choice because the preload scanner cannot discover it from HTML; CSS background images as LCP elements covers how to fix that.
Why do elements removed from the DOM not count?
Since Chrome 88, if an LCP candidate is removed from the DOM, the browser does not report it as the final LCP — it considers the next-largest remaining element. This is why a skeleton or placeholder that is replaced by real content stops being a candidate, and why a hydration re-mount produces a new, later candidate rather than keeping the original timing.
Related
- Fixing LCP when the H1 is the LCP element — optimising for a text winner.
- Why lab and field LCP disagree — the broader lab/field gap of which element instability is one cause.
- Fixing CLS from cookie consent banners — the layout side of late overlays.