The LCP Element on a Product Page Is Usually the Wrong Image
What LCP measures on a product page, why the hero image usually loses to the product title, and which shifts you can actually fix.
Priya Raghunathan · @praghunathan
Frontend and accessibility
- Topic
- PERF
- Section
- Performance
- Published
- 11 SEP 2026
- Reading time
- 8 MIN
- Words
- 1948W
A storefront came back from an agency rebuild with a Lighthouse score of 99 on the single product template. Four weeks of field data reported LCP at 3.9 seconds on the 75th percentile. The agency asked whether the field tool was broken. It was not. The lab run and the field data were grading different elements on the same page.
The product template carries things a lab run does not reproduce: per-viewport image transformations from a CDN, a gallery script that replaces the main image after DOMContentLoaded, an ad slot injected below the third paragraph, a webfont from a tag manager. Any one can change which element the browser paints first at full size.
The three metrics are well known. What gets skipped is finding out which element your page is graded on, and why it is so rarely the photograph the designer is proud of.
What each of the three metrics is actually timing
LCP is not a page score. It is a timestamp for one element: the largest contentful thing painted inside the viewport before the first interaction. On a desktop monitor that is often a hero image. On a 360-pixel phone, where the hero is clipped and the product title covers a third of the screen, the title can win. The bands are 2.5 seconds for good and 4.0 for poor, applied to the element's paint time, not to the page around it.
CLS is a sum, not an event, and it accrues while the page is open. Each unexpected shift scores by how much of the viewport the element covers and how far it travels. The bands are 0.1 and 0.25. A footer moving ten pixels is noise; a gallery frame collapsing by 400 pixels above the fold is most of your budget.
INP replaced FID as a Core Web Vital in March 2024. FID timed the delay before the browser started handling the first interaction. INP looks at interactions across the whole visit and reports a high percentile, so a slow variation selector on the twentieth tap counts. The bands are 200 milliseconds and 500. INP needs a real interaction, which makes it a field metric; a lab run reports Total Blocking Time instead.
| Metric | Common cause on a product page | Fix |
|---|---|---|
| LCP | Full-viewport hero background, dropped by Chromium's heuristics | Render the hero as a sized <img> element |
| LCP | Hero carries loading="lazy" above the fold | Drop lazy, add fetchpriority="high" |
| LCP | Render-blocking font stylesheet delays the product title | Self-host, preload the woff2, use font-display: optional |
| INP | Variation change re-renders price and stock synchronously | Do less in the handler; move the heavy path off the interaction |
| CLS | Gallery frame has no width, height or ratio | Set width/height or aspect-ratio on the frame |
| CLS | Ad container injected after load with no reserved space | Reserve the slot's height in CSS before the script runs |
Lab data and field data disagree because they sample different populations
A Lighthouse run loads one URL, once, with an empty cache, on a throttled connection and a simulated mid-tier CPU. No consent banner, no A/B assignment, no session, no cart. Field data is a 28-day rolling aggregate of real sessions at the 75th percentile: cheap Android phones on congested networks, return visitors with warm caches, users three hops from the origin, everyone bucketed into the experiment someone forgot to turn off.
Both numbers are honest. The lab run answers whether your last change made this template slower, which is why it belongs in CI. The field number answers what people experienced, and it is the only one Google uses.
When the two disagree, the field number wins and the lab number becomes a tool for finding the cause. The exception is the page with no field data at all. Pages below the public dataset's traffic threshold return nothing, and on a store doing forty orders a day most product URLs are in that bucket. Instrument LCP, INP and CLS yourself and send the values to your own endpoint, because on a low-traffic catalogue there is no other source.
The browser picks the LCP element, and a lazy hero disqualifies itself
LCP candidates come from a short list: <img> elements, an
<image> inside SVG, a <video> poster, an element with a
url() background image, and block-level text. Chromium then applies heuristics that throw
candidates out: opacity 0, placeholder or low-entropy images, and elements covering the full viewport,
on the reasoning that a full-screen element is probably a background rather than the content. A hero
sized to the whole viewport loses on that last rule, and the largest remaining candidate is often the
product title.
Heuristics are not the only filter. A candidate has to be painted, and an image's size is the smaller of its visible area and its intrinsic size, so a hero extending a third of its height below the fold is measured on the visible two thirds. An image still in flight has not painted, so the browser keeps the largest candidate it has already seen.
That last rule is why loading="lazy" on an above-the-fold hero is self-inflicted. The
attribute defers the fetch until the browser has parsed enough CSS and layout to decide the element is
near the viewport, and on a template with a render-blocking stylesheet that decision arrives after the
blocking chain. Two outcomes follow. Either the hero paints eventually and is reported late, or it
never wins and the reported element is the product title, which makes the number look healthy while
the visitor stares at an empty frame. The second is worse, because it hides the problem.
<!-- Above the fold on single-product.php: eager by omission, sized, high priority. -->
<img
src="/wp-content/uploads/2026/09/hero-800.avif"
srcset="/wp-content/uploads/2026/09/hero-400.avif 400w,
/wp-content/uploads/2026/09/hero-800.avif 800w,
/wp-content/uploads/2026/09/hero-1600.avif 1600w"
sizes="(max-width: 600px) 100vw, 50vw"
width="800" height="800"
alt=""
fetchpriority="high"
decoding="async">
There is no loading attribute in that tag, because eager is the default and correct
above the fold. fetchpriority="high" raises the request's priority against the other
images in flight; browsers that do not support it ignore it, which is the right failure mode. A
preload next to a lazy attribute is a contradiction that wastes the preload.
Check the rendered HTML, not the template
If your theme renders the hero through wp_get_attachment_image(), do not assume an
attribute array survives to the front end. Core's handling of priority hints inside that function
has changed across releases, and image optimisation plugins filter the markup again. View source
and confirm the attribute is on the tag.
A webfont that arrives late moves both LCP and CLS
When the LCP element is the product title, the font is the LCP. A render-blocking stylesheet puts the text paint behind a DNS lookup, a TLS handshake and a font download, all before a single glyph renders. No change to the image pipeline fixes that.
The reflex is font-display: swap, which paints the fallback immediately and lets the
real font replace it whenever it lands. That solves LCP and creates CLS, because a swap between fonts
with different metrics reflows the text. On a 360-pixel viewport a long title set in the fallback can
lose or gain a line, and everything below it moves. font-display: optional is the cheaper
trade: if the font is not ready when the text paints, the fallback stays for that visit.
Three things earn most of the win. Self-host the woff2 so the request sits on your own origin,
subset it to the glyphs the template uses, and preload the one file the title needs. A font injected
by a tag manager, or by a script that appends a <link> after paint, guarantees the
swap lands at the worst moment. If the heading font is a third-party request you cannot control, use
a different heading font.
An ad slot with no reserved height is the most expensive shift on the page
Ad markup is injected after load, when the auction returns. With no height on the container, the browser relayouts the paragraph below it, the related products block, and the footer. Shift score grows with distance travelled and viewport coverage, so a tall creative near the top of the article is the worst case you can build. Reserving the slot's height in CSS before any script runs removes the shift at the source.
The tradeoff is honest and worth saying out loud: when no ad fills, you show an empty block and
someone will complain about the gap. The gap is cheaper than the shift. A fixed
min-height on the container, set per breakpoint to match the creative sizes you serve, is
the whole fix. CSS containment does not exempt the shift from the score.
The gallery fails the same way for different reasons. WooCommerce's product gallery replaces the
main image when a thumbnail is selected, and if the frame derives its height from the image bytes,
every swap is a shift and so is the first load. Give the frame a ratio and let the image fill it:
browsers derive one from the width and height attributes, and CSS
aspect-ratio does the same without touching the template.
/* Reserve the slot before the ad script runs. Match your real creative sizes. */
.wpsifero-ad-slot { min-height: 280px; }
@media (min-width: 900px) { .wpsifero-ad-slot { min-height: 600px; } }
/* Derive the gallery frame from the box, not from the bytes. */
.woocommerce-product-gallery__image {
aspect-ratio: 1 / 1;
}
.woocommerce-product-gallery__image img {
width: 100%;
height: 100%;
object-fit: contain;
}
A green Lighthouse score is not what your visitors get
Here is the limit, and it is why I stopped reporting lab scores to store owners. Lighthouse's throttling is a model of a device, not a device. It measures a cold first load of one URL with no consent banner and no tag manager, and it cannot measure INP because there is no user to interact. A template can score 100 and still give a real customer a four-second LCP, because that customer's page includes the ad auction and the personalisation script the lab run never executed.
Field data has its own blind spots. The public dataset is a 28-day trailing window, so the week after a fix you are reading a blend of before and after. On low-traffic URLs there is no record at all. A 75th percentile drawn from a few hundred sessions is noisy enough that a two-week swing can be traffic mix rather than your change.
Optimising the synthetic number on its own is how teams end up engineering against an audit checklist instead of a storefront: deferring scripts the layout depends on, inlining critical CSS until the template is unmaintainable, shipping system fonts on a brand that paid for a typeface. Each can raise the score and lower the experience. Use the audit to catch regressions, and decide what to fix from field data and the element the browser chose.
What to do on Monday
- Open the single product template on a real product URL with the ads and the gallery script running, and record which element is the LCP element. Not the score: the element.
- If it is not the hero, find out why: a full-viewport background, a lazy attribute, hidden until a script runs, or smaller than the title once clipped to the viewport.
- Make the hero a real
<img>with width and height, no lazy attribute, andfetchpriority="high". Confirm the attribute survived to the rendered page. - Preload the woff2 the title uses, and switch it to
font-display: optional. Watch CLS around first paint for a day. - Reserve the ad slot's height per breakpoint, and put an aspect ratio on the gallery frame.
- Leave the lab run in CI as a regression gate, and read the field number four weeks later before you claim anything.
Priya Raghunathan · @praghunathan
Frontend and accessibility
Reviews interfaces for what happens when JavaScript fails, the keyboard is the only input, and the connection is slow. Focused on Core Web Vitals, semantic markup and progressive enhancement.
Continue