Performance Cwv
LCP Is Often Text, Not Your Hero Image
Reading level
You optimized the wrong element
Your team spends two sprints optimizing the hero image — WebP, lazy loading, CDN, srcset. Lighthouse LCP score barely moves. You dig into the filmstrip and finally notice: the browser flagged the headline text as the Largest Contentful Paint element, not the image. All that image work had zero effect on LCP.
LCP is whichever element has the largest visible area when it first renders — and that element is often text on content-heavy pages. Developers assume LCP = hero image because that's the biggest-looking thing. But "largest" is measured by area, not aesthetics. A wide page-width heading in a large font can easily outweigh a cropped or offscreen image.
LCP element identification should be the first step in any LCP improvement sprint — not an assumption. The Chrome Performance panel, PerformanceObserver with 'largest-contentful-paint', and CrUX's LCP breakdown all identify the actual element. Skipping this step is how teams spend weeks on image optimisation that doesn't move the metric.
What LCP actually measures
Largest Contentful Paint measures when the biggest element visible in the viewport finishes rendering. The browser considers:
- Images (including background-image)
- Video poster images
- Block-level text elements (p, h1-h6, div with text)
- SVG elements
It picks the largest one by area (width × height) among those that are in the viewport when they paint. That's often a heading in a large font, or a body paragraph that fills the full width.
To identify your LCP element programmatically:
new PerformanceObserver((list) => {
const entries = list.getEntries();
const last = entries[entries.length - 1];
console.log('LCP element:', last.element, 'time:', last.startTime.toFixed(0) + 'ms');
}).observe({ type: 'largest-contentful-paint', buffered: true });
Or: Chrome DevTools Performance panel → filmstrip → hover the LCP marker → the highlighted element is your LCP target.
LCP is the composite of: resource load time (for images/video) or render-blocking time (for text waiting on fonts), plus DOM processing time, plus render time. For text LCP: if the font is render-blocking, LCP time = font load time. Preloading the font or using system fonts for the LCP element directly improves the metric without any image work.
LCP fix strategies by element type
If LCP is an image:
- Add
<link rel="preload" as="image" href="hero.webp">in<head> - Remove
loading="lazy"from the LCP image - Use WebP/AVIF format
- Serve from CDN edge closest to user
If LCP is text:
- Preload the font:
<link rel="preload" as="font" href="font.woff2" crossorigin> - Use
font-display: optional— text renders in fallback immediately, no wait - Or: use a system font for above-fold headings
The biggest LCP wins for text elements:
- Eliminate render-blocking resources — inline critical CSS; defer non-critical JS
- Preload the LCP font — prevents the font from delaying text render
- Server-side render the LCP element — text in the initial HTML paints faster than text injected by JavaScript
LCP attribution in CrUX (PageSpeed Insights → "Diagnose performance issues") breaks down LCP into: TTFB + resource load delay + resource load time + render delay. For text LCP, "resource load delay" is usually the font. "Render delay" is usually JS blocking the main thread. Both are individually addressable without touching image assets.
The fix was a font preload, not an image
The dev opened Chrome DevTools, went to the Performance tab, and recorded a page load. In the Web Vitals section of the filmstrip, the LCP marker appeared at 3.2 seconds. She hovered over it. The highlighted element was not the hero image — it was the large <h1> headline at the top of the page, set in a custom typeface called "Canela Display."
The font was loading from a CDN with no preload hint. The browser only discovered it when the CSS was parsed, which was after the HTML finished loading. The heading text was invisible (FOIT — Flash of Invisible Text) for 800ms while the font downloaded. That 800ms delay was the entire LCP problem. Adding one line to the <head> fixed it.
<link rel="preload" as="font"
href="/fonts/canela-display.woff2"
type="font/woff2" crossorigin>
Adding font-display: swap to the @font-face rule let the browser render the heading immediately in a fallback system font, then swap to Canela Display when it loaded. This eliminated FOIT entirely. Combined with the preload hint (which moved font discovery from "after CSS parse" to "alongside HTML parse"), LCP dropped from 3.2s to 2.4s — an 800ms improvement with two changes, neither of which touched the hero image they'd been optimizing for two sprints.
@font-face {
font-family: 'Canela Display';
src: url('/fonts/canela-display.woff2') format('woff2');
font-display: swap; /* render in fallback immediately */
}
The postmortem measurement: CrUX data for the page showed "resource load delay" as the dominant LCP phase — the interval between navigation start and when the browser began loading the LCP resource. For text LCP, this phase is the font discovery gap (time from navigation start to when the browser encounters the font reference). Preloading collapsed this phase from ~1.4s to ~0.1s. The "render delay" phase (time from resource load complete to LCP paint) was reduced by font-display: swap. Together, they addressed the two largest phases in the LCP attribution breakdown without touching image assets, CDN config, or any of the other optimizations the team had been working on.
Before — hero image assumed to be LCP
<!-- Hero image — team added fetchpriority="high" -->
<img src="/hero.webp" fetchpriority="high" loading="eager"
alt="Summer collection" width="1200" height="600" />
<!-- Banner text — rendered by JS after A/B flag resolves -->
<div id="promo-banner"></div>
<script>
abFlag.ready(() => {
document.getElementById('promo-banner').innerHTML =
'<h1>Summer sale — 40% off</h1>';
});
</script>
After — LCP element identified and unblocked
<!-- Static headline — LCP element, no JS dependency -->
<h1 class="promo-headline">Summer sale — 40% off</h1>
<!-- Hero image — still optimised but no longer the LCP bottleneck -->
<img src="/hero.webp" loading="eager"
alt="Summer collection" width="1200" height="600" />
Try it: LCP element identification
Toggle between "Broken" (lazy-loaded image + render-blocking font — LCP is slow) and "Fixed" (preloaded font + eager image + no render block). Open Lighthouse to see the LCP score difference.
The demo simulates a page where the heading is the LCP element. Notice the broken version waits for the font before rendering the heading — that delay is the LCP time.
Run the PerformanceObserver snippet in the console on both versions. Compare the startTime values — the element and root cause should both be clear in the output.
Showing: Fixed — optimized LCP
Identifying your LCP element and fixing it
Step 1: find your LCP element. Open Chrome DevTools → Performance tab → click Record → reload the page → stop recording. In the "Web Vitals" lane, find the LCP marker. Click it. The element is highlighted in the filmstrip and named in the details panel. That element — not the hero image, not what you think it is — is your LCP target.
Two common fixes by element type:
- Text LCP (heading, paragraph): add
<link rel="preload" as="font">for the font used, andfont-display: swaporfont-display: optionalon the@font-facerule. - Image LCP: add
fetchpriority="high"to the<img>tag, remove anyloading="lazy", and add<link rel="preload" as="image">if the image is in CSS rather than HTML.
The as attribute on <link rel="preload"> determines priority and matching — get it wrong and the browser fetches the resource twice:
<!-- Font preload: requires crossorigin even same-origin -->
<link rel="preload" as="font"
href="/fonts/display.woff2" crossorigin>
<!-- Image preload: use for CSS background-image LCP -->
<link rel="preload" as="image" href="/hero.webp">
<!-- fetchpriority: signals priority to the browser's
preload scanner for <img> elements in HTML -->
<img src="hero.webp" fetchpriority="high" alt="...">
Critical distinction: <link rel="preload" as="font"> always requires crossorigin, even for same-origin fonts — without it, the browser fetches the font twice (once from the preload, once from the CSS). fetchpriority="high" on an <img> is different from preloading — it boosts priority within the browser's resource scheduler but doesn't move discovery earlier. Use both together for image LCP.
The interaction between LCP and font-display is nuanced. font-display: swap eliminates FOIT (invisible text) but introduces FOUT (flash of unstyled text when the custom font swaps in). For LCP, swap is usually correct — the fallback text paints immediately, satisfying LCP. font-display: optional is stricter: if the font doesn't load within a very short window (~100ms), it's abandoned for that page load entirely — no swap, no FOUT, but the custom font may not appear. This makes optional the best choice when visual consistency matters less than LCP time. Use CrUX LCP attribution data (PageSpeed Insights → "Diagnose performance issues" → LCP breakdown) to see the four phases for your real users: TTFB, resource load delay, resource load time, and render delay. Address the largest phase. Font preload primarily attacks "resource load delay." font-display primarily attacks "render delay." They target different phases and compound when combined.
References
Remember
Key takeaways
-
Always identify the actual LCP element before optimizing — use DevTools Performance filmstrip or the PerformanceObserver snippet.Text is frequently the LCP element on content pages. Preloading fonts or using font-display: optional often has more LCP impact than image optimization.LCP attribution in CrUX breaks down the metric into four phases — address the largest phase first, not the most obvious one.
-
Never put loading="lazy" on the LCP image — eager loading is default and correct for above-the-fold images.Inline critical CSS and defer non-critical JS to eliminate render-blocking that delays text LCP.SSR the LCP element — text in initial HTML paints before any JS runs, making it the fastest possible path to LCP for text elements.
Keep going
Finish this takeaway, then continue the track — Casey saved your spot locally.
Sign in with email to sync progress across devices (beta).
Inside the Casebook
New cases every few weeks — patterns from production UI engineering. Double opt-in, easy unsubscribe.
No spam. Unsubscribe anytime. Emails sent via Buttondown.
RSS feed