Skip to content

Optimizing Largest Contentful Paint (LCP) in Modern Web Apps

Core Concept LearningAugust 3, 20269 min read

"Optimize LCP" is not one task, because Largest Contentful Paint is not one number you move with one fix — it's the sum of four distinct phases, each with a different root cause and a different remedy, and treating it as a single blob leads teams to compress an image that was never the bottleneck while the real problem (a render-blocking font, or a hero image discovered only after JavaScript executes) sits untouched. The fix that works is always specific to whichever phase is actually large for your page, which you only know by measuring the breakdown, not by guessing from general performance advice.

This guide takes one running example — a product page whose largest contentful element is a hero product photo — through all four LCP phases: Time to First Byte, resource load delay (how late the browser discovers the image needs loading), resource load time (how long the image itself takes to download), and render delay (time between the image being ready and the browser actually painting it). You'll see how to read the phase breakdown from Chrome DevTools or a field-data tool, the fix specific to each phase, and the real failure of a background-image CSS hero that the browser can't preload — which no image compression fixes. For the broader Core Web Vitals picture this sits inside, see optimizing frontend performance with Core Web Vitals and INP optimization.

LCP is four measurable phases, not one number
LCP is four measurable phases, not one number

LCP is four phases, not one number

Time to First Byte (TTFB) is the time from navigation start to the first byte of the HTML document arriving — everything else in the page's timeline starts after this, so a slow TTFB delays every subsequent phase by the same amount regardless of how well-optimized the rest of the page is. Resource load delay is the gap between the HTML arriving and the browser actually starting to fetch the LCP element's resource (usually an image) — this is large when the image is discovered late, for example because it's set via JavaScript after hydration, or its URL is buried inside a CSS file the browser hasn't parsed yet.

Resource load time is the actual download duration of the LCP resource once the browser starts fetching it — large when the image is unoptimized (wrong format, no compression, oversized dimensions) or served without a CDN close to the user. Render delay is the gap between the resource being ready and the browser painting it — large when the main thread is busy with JavaScript execution at exactly the moment the image is ready, blocking the paint. Each phase needs a completely different fix, and Chrome DevTools' Performance panel (or the Web Vitals extension) shows this breakdown directly under the LCP entry — read it before touching anything.

Quick reference

  • TTFB improvements are server/infra work: caching, edge rendering, database query speed — not an image or CSS problem at all.
  • Load delay is almost always a discovery problem: the browser's preload scanner can't find the image URL early enough because it's set by JS or hidden in CSS.
  • Load time is the classic image-optimization problem: format, compression, and dimensions — but only the actual bottleneck if load delay is already small.
  • Render delay often traces back to a main-thread-blocking script executing right as the image finishes loading — the image was ready, but the browser was busy.

Remember this

Read the four-phase breakdown before optimizing anything — compressing an image fixes load time, but does nothing if your actual bottleneck is load delay or render delay.

Fixing load delay: help the preload scanner find the image

The browser's HTML preload scanner starts fetching resources it can see directly in the initial HTML — an <img src="..."> tag or a <link rel="preload"> — before JavaScript has even executed. A hero image set via a CSS background-image property, or rendered by client-side JavaScript after a data fetch, is invisible to the preload scanner: the browser can't start downloading it until CSS is parsed or JS has executed and updated the DOM, which can be hundreds of milliseconds to seconds later on a slow connection or a JS-heavy page. This is the single most common LCP regression introduced by modern component frameworks, because a <Hero style={{ backgroundImage: ... }}> pattern looks identical to a plain <img> in the rendered page but behaves completely differently to the preload scanner.

The fix: use a real <img> tag (or Next.js's <Image priority>) for anything that's the actual LCP candidate, and add fetchpriority="high" so the browser prioritizes it over other concurrent requests even when it is discoverable early. Where the image genuinely can't be a plain <img> (a decorative CSS background that also happens to be the largest element), add an explicit <link rel="preload" as="image" href="..."> in the document head so the preload scanner has an early hint regardless of where the CSS reference lives.

Helping the preload scanner discover the LCP image early
Helping the preload scanner discover the LCP image early

Quick reference

  • Any background-image that is your actual LCP candidate should become a real <img> — no compression setting recovers the time lost to late discovery.
  • fetchpriority="high" (or Next.js <Image priority>) tells the browser to prioritize this fetch over other concurrently discovered resources, not just discover it earlier.
  • Never lazy-load (loading="lazy") the LCP image — lazy-loading defers the fetch until layout confirms visibility, which directly adds load delay to the element you most need painted fast.
  • If the LCP element is genuinely rendered by client JS (a data-dependent hero), server-render it instead, or fetch that specific data server-side so the HTML already contains the image URL.
CSS background-image — invisible to preload scanner
1<div style={{ backgroundImage: "url(/hero.jpg)", height: 480 }} />2// The preload scanner can't see this URL until CSS is parsed —3// on a slow connection, that's hundreds of ms of pure discovery delay.
Real <img> with priority hint
1import Image from "next/image";2 3<Image4  src="/hero.jpg"5  alt="Product hero"6  width={1200}7  height={480}8  priority // adds fetchpriority="high" and skips lazy-loading for this image9/>10// Next.js's <Image priority> is discoverable by the preload scanner11// immediately, and explicitly deprioritizes competing requests.

Remember this

The preload scanner can only prioritize what it can see in raw HTML — a CSS background or JS-rendered image adds real load delay no amount of image compression recovers.

Shrinking load time and clearing render delay

Once discovery is fixed, load time is the classic image-optimization work: serve modern formats (AVIF, WebP) that compress meaningfully better than JPEG/PNG at equivalent visual quality, serve the image at the actual rendered dimensions (not a 4000px source scaled down by CSS, which downloads pixels the browser throws away), and put the image behind a CDN edge close to the user so the download itself is fast regardless of your origin server's location. Next.js's <Image> component automates the format negotiation and responsive srcset generation, which is most of this work done correctly by default rather than by hand-tuning each image.

Render delay is different — the image can be fully downloaded and ready, but the browser is busy executing a long JavaScript task on the main thread at exactly the moment it would otherwise paint, so the paint is delayed until the main thread frees up. This is the same root cause covered in depth in INP optimization: break up long tasks, defer non-critical JavaScript (analytics, third-party widgets) so it doesn't compete with the initial paint, and check whether a large client-side hydration pass is the actual culprit blocking the paint of an already-downloaded image.

Zoom: render delay — image ready, main thread busy
Zoom: render delay — image ready, main thread busy

Quick reference

  • AVIF/WebP typically compress 25-50% smaller than equivalent-quality JPEG — real savings, but only matters if load delay isn't your actual bottleneck.
  • Serve images at their rendered size via srcset/sizes (Next.js <Image> generates this automatically) — a source image 3x larger than its display size wastes download time proportionally.
  • Defer non-critical third-party scripts (analytics, chat widgets) so they don't compete with the main thread at the exact moment the LCP image is ready to paint.
  • A large client-side hydration pass can visibly delay paint of an already-loaded image — check Total Blocking Time alongside LCP, not LCP alone, when render delay is the suspected phase.

Remember this

Load time is solved by format, sizing, and CDN placement; render delay is solved by keeping the main thread free at paint time — they require entirely different work, so confirm which one is actually large before choosing.

Measuring correctly: lab data vs. field data, and the decision rule

Lab tools (Lighthouse, local Chrome DevTools) measure LCP under one simulated network/CPU condition on one run — useful for debugging a specific phase, but not representative of your real user population's mix of devices and connections. Field data (Chrome User Experience Report / CrUX, or your own Real User Monitoring via the web-vitals library) reflects actual users' LCP distribution, which is what Google's Core Web Vitals thresholds (good: ≤2.5s at the 75th percentile) are actually evaluated against — a page that looks fast in a lab run on a fast machine can still fail the field threshold if a meaningful share of real users are on slower devices or networks.

The decision rule: use lab data to diagnose which of the four phases is large and iterate on a fix quickly, then confirm the fix moved the field 75th-percentile LCP, not just your local lab number — a fix that only improves the median while the 75th percentile stays high hasn't actually met the threshold that matters for Core Web Vitals scoring and search ranking signals.

Quick reference

  • Lab data (Lighthouse) is fast to iterate on but represents one device/network condition — good for diagnosis, not for confirming you've met a real-user threshold.
  • Field data (CrUX, or web-vitals RUM) reflects your actual user population's 75th-percentile LCP — that's the number evaluated against Google's Core Web Vitals thresholds.
  • A fix that improves the lab score but not the field 75th percentile likely optimized a phase that wasn't actually the bottleneck for your real traffic mix.
  • Segment field LCP by device class and connection type when available — a fix that helps desktop users can be invisible to the mobile-network majority driving your 75th percentile.

Remember this

Diagnose with lab data (fast iteration, one phase at a time), but confirm success against field-data's 75th-percentile LCP — that's the number Core Web Vitals thresholds actually evaluate.

Key takeaway

Take a page with a hero image set as a CSS background-image and record its LCP phase breakdown in Chrome DevTools' Performance panel (or PageSpeed Insights' field-data view). Expected starting point: a large load-delay phase, since the preload scanner can't discover a CSS background-image early. Convert the hero to a real <Image priority> (or <img fetchpriority="high">), re-measure, and confirm load delay shrinks substantially — that's the fix that addresses discovery, not compression. Then intentionally break it again: add loading="lazy" to that same image and re-measure to see load delay reappear, confirming lazy-loading and LCP priority are directly in conflict for whichever element is the actual LCP candidate. Recovery: remove the lazy attribute. Pass criterion: the phase breakdown shows load delay reduced by switching to <img priority>/fetchpriority="high", and you can reproduce the regression on demand by re-adding loading="lazy" — proving you've identified the actual causal phase, not just correlated with an improvement.

Share:

Related Articles

Next.js 16 introduces powerful performance primitives for modern React applications. With refined React Server Component

Read

Modern web applications must deliver instantaneous visual rendering and smooth, latency-free user interactions. Heavy Ja

Read

Next.js 16 continues the evolution of web application architecture, refining React Server Components (RSC), introducing

Read

Keep learning

Follow a structured path or browse all courses to go deeper.