Skip to content

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_starttransfertime_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

<Image src={hero} alt="…" priority sizes="100vw" />   // sets fetchpriority="high" + preload

Cause B3 — Lazy‑loaded above the fold

# Find lazy attributes that might be on above-fold images
rg -n 'loading="lazy"' app components

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

<link rel="preconnect" href="https://images.auroramarket.com" crossOrigin="anonymous" />

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 */}
fetch('/api/recommendations', { priority: 'low' });

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:

./scripts/perf-compare.sh "$BASELINE_URL" "$CANDIDATE_URL" 7

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
  • priority on exactly the LCP element
  • AVIF/WebP enabled
  • Preconnect to the image origin
  • No entrance animation, lazy‑loading, or content-visibility on the LCP element
  • TTFB under 400 ms (see 6.4)
  • Lighthouse CI assertions + an LCP‑element Playwright test

Next: 6.2 INP playbook