6.1 — LCP playbook¶
Module 6 · Lesson 1 · 🟡 Intermediate · ~40 min
A symptom → cause → fix reference. Start with the diagnosis flow, jump to the matching section.
Step 1 — Get the sub‑part breakdown¶
Never guess. LCP has four sub‑parts, and the fix depends entirely on which one dominates.
import { onLCP } from 'web-vitals/attribution';
onLCP(({ value, attribution }) => {
console.table({
total: value,
ttfb: attribution.timeToFirstByte,
loadDelay: attribution.resourceLoadDelay,
loadTime: attribution.resourceLoadDuration,
renderDelay: attribution.elementRenderDelay,
element: attribution.element,
url: attribution.url,
});
});
For a single page in DevTools: Performance panel → LCP marker in the Timings track → the summary shows the element. Cross‑reference with the Network panel for the resource's request and response times.
Aurora PDP, p75 mobile, before any work:
TTFB ████████████████████ 910ms (20%)
Load delay ████████████ 540ms (12%)
Load time ████████████████████████████████████████ 2,890ms (63%)
Render delay ████ 260ms (5%)
─────
4,600ms
Fix the biggest bar first. Here it's load time — a 340 KB JPEG. Everything else is noise until that's solved.
Step 2 — Identify the LCP element¶
You cannot fix what you can't name.
// Paste into the console — logs the LCP element as it's determined
new PerformanceObserver((list) => {
const entries = list.getEntries();
const last = entries[entries.length - 1];
console.log('LCP element:', last.element, {
size: last.size,
url: last.url,
renderTime: last.renderTime,
loadTime: last.loadTime,
});
}).observe({ type: 'largest-contentful-paint', buffered: true });
In the field, attribution.element gives you the selector. Aggregate it:
SELECT page_type, lcp_element_selector, COUNT(*) AS n,
APPROX_QUANTILES(value, 100)[OFFSET(75)] AS p75
FROM vitals WHERE name = 'LCP' AND device_class = 'mobile'
GROUP BY 1, 2 ORDER BY n DESC;
If your LCP element varies across sessions on the same page type, that's itself a bug — usually a carousel, a conditionally‑rendered banner, or an A/B variant. Stabilize it first; otherwise your measurements are noise.
Symptom A — High TTFB (> 800 ms)¶
The server is slow. Everything downstream waits.
| Cause | Diagnosis | Fix |
|---|---|---|
| Route is dynamic when it needn't be | next build shows ƒ |
3.1 |
| Sequential data fetching | Server timing / APM shows a waterfall | 7.1 |
| No CDN caching of HTML | x-cache: MISS, age: 0 on repeat requests |
2.4 |
| Middleware doing work | server-timing: mw;dur=… |
3.7 |
| Cold starts | High p95/p99, low p50 | 8.6 |
Slow generateMetadata |
TTFB high even with a fast page body | 3.3 |
| Not streaming | time_starttransfer ≈ time_total |
3.3 |
| Origin far from user | Geographic TTFB variance in RUM | 8.6 |
Full treatment: 6.4 TTFB playbook.
The fastest check:
curl -sI https://www.auroramarket.com/p/wool-overshirt-navy \
-w 'ttfb: %{time_starttransfer}s total: %{time_total}s\n' -o /dev/null
curl -sI https://www.auroramarket.com/p/wool-overshirt-navy | grep -iE 'x-cache|age|x-nextjs-cache'
Symptom B — High resource load delay (> 300 ms)¶
The image was discovered late. This is the most fixable sub‑part and the most common React bug.
Cause B1 — The image isn't in the HTML¶
curl -s https://www.auroramarket.com/p/wool-overshirt-navy | grep -c 'images.auroramarket'
# 0 → the preload scanner can't see it. This is your bug.
Fix: server‑render the LCP image. See 2.1 Images § Bug 1.
Cause B2 — No priority¶
Cause B3 — Lazy‑loaded above the fold¶
next/image lazy‑loads by default; priority opts out. Verify your LCP element isn't lazy.
Cause B4 — A long request chain before the image¶
The image is discovered only after CSS/JS/fonts resolve. In DevTools → Performance → the "Initiator" column in Network shows the chain. Break it by preloading:
// In a Server Component — React 19 preload API, runs during render
import { preload } from 'react-dom';
export default async function ProductPage({ params }) {
const product = await getProduct((await params).slug);
preload(product.heroImageUrl, { as: 'image', fetchPriority: 'high' });
return /* … */;
}
Cause B5 — Connection to the image origin not warmed¶
Cause B6 — A consent banner or A/B script gates the image¶
The image renders only after a third‑party responds. Restructure so the image is unconditional and the variant affects something else, or resolve the A/B assignment at the edge (8.5).
Symptom C — High resource load time (> 1,000 ms)¶
The image is too big for the connection. Usually one of three things.
Cause C1 — Wrong sizes (most common)¶
// Console audit — see 2.1 for the full version
[...document.images].map(i => ({
src: i.currentSrc.split('/').pop(),
intrinsic: i.naturalWidth,
needed: Math.round(i.getBoundingClientRect().width * devicePixelRatio),
})).filter(r => r.intrinsic > r.needed * 1.5);
Cause C2 — Wrong format¶
JPEG where AVIF would do. Enable formats: ['image/avif', 'image/webp'].
| Format | 828px product photo |
|---|---|
| JPEG q80 | 178 KB |
| WebP q75 | 112 KB |
| AVIF q50 | 68 KB |
Cause C3 — Quality set too high¶
Product photography at q90 is indistinguishable from q72 on a phone screen and 2× the bytes. Test at q65–q75 for AVIF, q75–q80 for WebP. Have a designer compare on an actual phone, not a 5K monitor.
Cause C4 — Bandwidth contention¶
The image is competing with 12 JS chunks, 3 fonts, and a tag manager. Even a well‑sized image arrives late.
// Deprioritize what competes
<link rel="preload" href="/fonts/brand.woff2" as="font" crossOrigin /> {/* only 1-2 fonts */}
And defer third parties (2.3) — this is often worth more to LCP than any image change.
Symptom D — High render delay (> 300 ms)¶
The image downloaded but couldn't paint.
| Cause | Diagnosis | Fix |
|---|---|---|
| Render‑blocking CSS | Large CSS in <head>; FCP ≈ CSS completion |
2.5 |
| Font blocking text LCP | LCP element is text; font-display: block |
2.2 |
| Main thread busy | Long task overlapping LCP time in the trace | 4.4, 2.3 |
| Client‑side gating | if (!mounted) return null around the hero |
Server‑render it |
content-visibility above the fold |
CSS audit | Remove it above the fold |
| Animation delaying paint | Fade‑in on the hero with a delay | Remove the delay on the LCP element |
// ❌ A 400ms fade-in on the hero adds 400ms to LCP.
// LCP is measured at the paint, and an element at opacity: 0 hasn't painted.
<div className="animate-fade-in-slow">
<Image src={hero} priority />
</div>
// ✅ No entrance animation on the LCP element
<Image src={hero} priority />
The fix ranking¶
When you don't know where to start, this is the expected‑value order for a commerce site that has never done LCP work:
| Rank | Fix | Typical gain | Effort |
|---|---|---|---|
| 1 | Correct sizes on the LCP image |
−400 to −1,500 ms | 10 min |
| 2 | Server‑render the LCP image (out of a client carousel) | −500 to −2,000 ms | 2–8 h |
| 3 | priority / fetchpriority="high" |
−200 to −600 ms | 5 min |
| 4 | AVIF/WebP | −200 to −800 ms | 1 h |
| 5 | Make the route cacheable (ISR/PPR) | −400 to −900 ms | days |
| 6 | Defer/facade third parties | −200 to −900 ms | days |
| 7 | Preconnect to the image origin | −100 to −300 ms | 5 min |
| 8 | Streaming SSR | −300 to −1,500 ms | days |
| 9 | Reduce render‑blocking CSS | −100 to −400 ms | days |
| 10 | Font optimization | −100 to −400 ms | hours |
Items 1, 3, 4, and 7 are under two hours combined and frequently deliver half the total win. Do them today, then do the architecture.
Verification¶
After each fix:
Lab:
Field: wait for the p75 to move. Segment by device class and connection type — a fix that only helps 4G users won't show up much in the blended number.
Regression protection:
// lighthouserc.js
assert: {
assertions: {
'largest-contentful-paint': ['error', { maxNumericValue: 2200 }],
'uses-responsive-images': ['error', { minScore: 0.9 }],
'modern-image-formats': ['error', { minScore: 0.9 }],
'prioritize-lcp-image': ['error', { minScore: 0.9 }],
'render-blocking-resources': ['warn', { maxNumericValue: 300 }],
},
}
Plus a Playwright assertion that the LCP element is what you expect:
test('PDP LCP element is the product image', async ({ page }) => {
await page.goto('/p/wool-overshirt-navy');
const selector = await page.evaluate(() => new Promise<string>((resolve) => {
new PerformanceObserver((l) => {
const e = l.getEntries().at(-1) as any;
resolve(e?.element?.tagName + '.' + (e?.element?.className ?? ''));
}).observe({ type: 'largest-contentful-paint', buffered: true });
setTimeout(() => resolve('none'), 5000);
}));
expect(selector).toContain('IMG');
});
This catches the regression where someone wraps the hero in a client carousel and the LCP element
silently becomes an <h1> — which looks fine in Lighthouse and is 800 ms slower in the field.
Aurora's LCP journey¶
| Change | p75 LCP (mobile PDP) |
|---|---|
| Baseline | 4,600 ms |
Fixed sizes (2200px → 828px) |
3,700 ms |
| AVIF | 3,280 ms |
priority + preconnect |
2,940 ms |
| Server‑rendered gallery slide 1 | 2,510 ms |
| Streaming SSR (TTFB 830 → 190 ms) | 2,180 ms |
| Third‑party deferral | 2,050 ms |
| PPR (TTFB 190 → 85 ms) | 1,940 ms |
Note the shape: the four cheap image fixes delivered 1,660 ms of the 2,660 ms total, in under a day of work. The architecture work delivered the rest, over a quarter.
Checklist¶
- LCP element identified and stable per page type
- Sub‑part breakdown collected in the field
- LCP image present in initial HTML
- Correct
sizes, verified against rendered width -
priorityon exactly the LCP element - AVIF/WebP enabled
- Preconnect to the image origin
- No entrance animation, lazy‑loading, or
content-visibilityon the LCP element - TTFB under 400 ms (see 6.4)
- Lighthouse CI assertions + an LCP‑element Playwright test
Next: 6.2 INP playbook