LCP is the largest candidate that survived until interaction
Most senior FE devs optimise for 'paint the big element fast'. The LCP metric actually tracks a moving marker through page load, excludes elements nobody intuits are excluded, and freezes on the first user interaction. Here's the candidate-eligibility story with a timeline you can watch and a tap-through exclusion checker.
Watch a simulated page load. Candidates surface over time; the LCP marker chases the largest eligible one until the user interacts:
- 0mstext block<header> block (before font loads)30K px²excluded
- 320mstext block<header> block (text painted)30K px²eligible
- 580ms<img>low-entropy placeholder.svg420K px²excluded
- 920msbackground-imagebody { background: url(hero.jpg) }900K px²excluded
- 1180ms<img><img> hero photo (loaded)540K px²eligible
- 1540mstext block<h1> headline (fallback font)240K px²eligible
- 2100mstext block<h1> headline (webfont swapped)240K px²eligible
- 2520ms<img><img> secondary hero (opacity: 0)620K px²excluded
- 3200ms<video><video> poster frame720K px²eligible
"Largest Contentful Paint" reads like the name of a single event: "the big thing painted". It isn't. LCP is a marker that moves through a list of candidates during page load. The marker jumps to whichever is currently the largest eligible paint. When the user first interacts, the browser freezes the marker and reports that candidate. Everything before the interaction is provisional. Everything after is ignored.
Most LCP confusion — "why is my hero image not counted?", "why does the metric keep changing in devtools?" — comes from reading the name and guessing the rest. The spec and web.dev's canonical article are specific. Reading them rewires the question.
LCP is the largest eligible paint candidate that was still the largest when the user first interacted. Candidates must be one of five element types (<img>, <svg image>, <video> poster, CSS background-image, or a block-level element with text). Several element/state combinations are excluded: full-viewport backgrounds, opacity: 0, placeholder / low-entropy images, and text that's still waiting on a web font. Only the initial size and position count — later layout changes don't create new candidates.
What counts as a candidate
Not every paint is a candidate. web.dev enumerates the five types explicitly:
The types of elements considered for Largest Contentful Paint are:
•
<img>elements (the first frame presentation time is used for animated content such as GIFs or animated PNGs)
•<image>elements inside an<svg>element
•<video>elements (the poster image load time or the first frame presentation time — whichever is earlier — is used)
• An element with a background image loaded using theurl()function (as opposed to a CSS gradient)
• Block-level elements containing text nodes or other inline-level text element children.
Five types. A <canvas> is not a candidate. A SVG <path> is not a candidate (only <image> inside SVG is). A flex container with no text is not a candidate unless it has a background-image.
That list alone clears up a whole class of bug reports. "I'm rendering our product hero through a custom <canvas> and the LCP is always my nav bar." Correct. Your hero isn't a candidate. What you're measuring is the largest eligible thing.
Eligibility isn't just "the type fits"
Within the candidate types, browsers apply eligibility filters. web.dev lists the heuristics directly:
Browsers apply heuristics excluding:
• Elements with an opacity of 0, that are invisible to the user
• Elements that cover the full viewport, that are likely considered as background
• Placeholder images or other images with a low entropy
Three exclusions with real consequences:
opacity: 0— An element hidden for animation (fade-in from 0) is not an LCP candidate until it's actually visible. Intuitive once you hear it, surprising when you don't. A big decorative image that fades in at t=800ms contributes nothing to LCP until that opacity moves off zero.- Full-viewport backgrounds — A
<body>withbackground: url(hero.jpg)doesn't count. A viewport-filling image is heuristically a background, not content. Move the same image into<img src="hero.jpg">and suddenly it is a candidate. - Low-entropy images — A grey 1×1 SVG scaled to 800×600 doesn't count. Placeholder images are uninformative. Chromium measures per-pixel information content and filters the lowest tier.
And one implicit but important exclusion: text block candidates don't count until the text has actually painted. Ship font-display: block, and while the browser is still in its block period the text is invisible — the block-level element isn't LCP-eligible yet. The moment the fallback font (or the web font) paints, the candidate becomes eligible at its rendered size, not its intended size.
Tap through a small set of scenarios to calibrate eligibility:
<img src="hero.jpg" width="1200" height="600" />The marker moves
Here's the piece that trips up people who only read the name. The metric isn't "the first big paint" — it's the largest eligible candidate so far, updated every time something larger paints, until the user interacts.
web.dev is explicit:
The browser will stop reporting new entries as soon as the user interacts with the page (via a tap, scroll, or keypress), as user interaction often changes what's visible to the user (which is especially true with scrolling).
So the LCP you observe on a page that takes 4 seconds to finish loading, with a user who clicks something at 800ms, is frozen at whatever was the largest eligible candidate at 800ms. The later-painting 1.5MB hero image doesn't matter if the user already scrolled past the area.
This is why real-world LCP values often don't match synthetic tests. In a synthetic run, the page "finishes" and the largest candidate wins. In a RUM sample, user behaviour truncates the measurement window. If your users interact fast, your LCP trends toward whatever's the largest candidate in the first few hundred milliseconds — which might be your nav or your skeleton loader, not your hero.
Size is "visible in viewport", not intrinsic
The other subtlety: size is measured against the viewport, not the element's own dimensions. A 4000×3000 image displayed in a 200×150 thumbnail slot contributes a size of 200×150, not 4000×3000. Exceptions: when the element has been scaled up past its intrinsic size, the intrinsic size is used (preventing gamification by transform: scale(10) on a tiny image).
This is why the candidate list in the timeline at the top of this post reports areas in pixels² — that's literally what the browser computes. Not bytes, not intrinsic resolution. Visible area in device-independent pixels, clamped by intrinsic size.
The initial position rule
One more detail that matters for layout-shift-heavy pages: only the element's initial size and position in the viewport are considered for the candidate. If the element moves or resizes later (for example, image-loaded reflow or a CLS-triggering font swap), the candidate's measurement doesn't update. A new candidate doesn't spawn either — it's the same element with the same initial metrics.
The practical consequence: a hero that lands off-viewport at first paint, then moves into view 200ms later because the header finally sized correctly, is measured at its off-screen first paint — size zero, not the intended hero size. The page's LCP will attach to something else entirely.
Optimising for the actual metric
The standard LCP advice — "preload your hero image", "use fetchpriority="high"", "minimise render-blocking resources" — is all correct, but the mental model under it is usually "paint the big thing fast". After reading the spec, a sharper model:
- Stop losing candidates to exclusions. If your hero is a CSS
background-imageon a container that's the full viewport, you've made it ineligible. Moving to<img>is a free LCP win — the candidate is now counted. - Stop making your largest element initially invisible.
opacity: 0fade-ins, off-viewport animations,display: none→blockswaps. Any of these pushes the start of the candidate window later. Use CSS animations that start from some visibility (e.g.,opacity: 0.01) if you really need a fade, or stop fading the hero. - Keep interactions small and intentional. Every user interaction freezes the metric. If you have a click-anywhere-to-dismiss onboarding overlay, that click is an interaction — LCP freezes at whatever was largest at that millisecond. Users who dismiss a hero to reveal content below have already locked their LCP in.
- Measure at the moment that matters. web.dev's
web-vitalslibrary implements the attribution: use it. Chrome's devtools Performance panel marks LCP candidates and the final choice.
Primary sources
- web.dev — Largest Contentful Paint (LCP) — the canonical definitional page: candidate types, exclusions, initial-position rule, interaction stop.
- W3C Largest Contentful Paint — the spec that implements LCP via the Performance Timeline and the
largest-contentful-paintentry type. - W3C Element Timing API — the lower-level "when did this element render?" primitive LCP builds on for some candidate types.
- web.dev — LCP attribution with web-vitals — how to surface the LCP candidate in code, useful for attribution debugging.