Largest Contentful Paint is the Core Web Vital people most often try to fix by guessing. They compress every image, defer every script, and the number barely moves — because LCP measures one element, and the work went somewhere else.
This is how to find that element, work out which part of its journey is slow, and fix that part specifically, in a Next.js App Router app.
LCP is one element, not a page score
LCP is the render time of the largest content block visible in the viewport: usually a hero image, a large heading, or the first paragraph of text. Not the whole page, not the last thing to load.
Find it before changing anything. PageSpeed Insights names the element directly. In Chrome DevTools, the Performance panel marks it with an LCP badge on the timeline, and hovering it highlights the node.
If the LCP element turns out to be a heading, every minute spent on image formats is wasted. That sounds obvious and it is the single most common failure in LCP work.
Break the number into four parts
Google splits LCP into four sub-parts, and the fix is completely different for each:
| Sub-part | What it is | Typical cause |
|---|---|---|
| Time to first byte | Server response | Dynamic rendering, slow upstream, cold start |
| Resource load delay | Gap between first byte and fetch starting | Discovered late, blocked, lazy-loaded |
| Resource load time | The download itself | Oversized image, no modern format |
| Render delay | Downloaded but not yet painted | Blocking CSS, fonts, hydration |
Measure before choosing. If TTFB is 1.8s, image optimisation is irrelevant. If render delay dominates, the file size is not your problem.
Time to first byte: render statically wherever you can
The fastest response is one that was rendered before the request arrived. In the App Router, a route is static unless something opts it out — reading cookies() or headers(), using searchParams, or setting cache: "no-store" on a fetch.
export const revalidate = 3600; // ISR: static HTML, refreshed hourly
export function generateStaticParams() { // prerender every post at build time
return getAllPosts().map((p) => ({ slug: p.slug }));
}Check what you actually shipped: next build prints a symbol per route. ○ is static, ● is prerendered with params, ƒ is rendered per request. A ƒ on a content page is usually an accident — one cookies() call deep in a shared component is enough to opt the whole route out. The caching rules behind this are worth understanding in full, which I covered in Next.js App Router caching.
Resource load delay: stop hiding the LCP image
next/image lazy-loads by default. That is correct for everything below the fold and wrong for the LCP element, because the browser will not fetch it until layout says it is near the viewport — after CSS, after layout, long after it could have started.
import Image from "next/image";
import hero from "../public/hero.webp";
<Image
src={hero}
alt="…"
priority // preloads, and disables lazy loading
sizes="(min-width: 1024px) 50vw, 100vw"
className="w-full"
/>priority does two things: it emits a <link rel="preload"> in the head so the fetch starts during HTML parsing, and it turns off lazy loading. Use it on exactly one image per page. Marking several defeats the purpose — preloading everything prioritises nothing.
A static import, as above, gives Next the intrinsic dimensions at build time, which matters for the next section too.
Resource load time: serve the right bytes
Once the fetch starts promptly, size is what remains:
- Let
next/imagere-encode. It serves AVIF or WebP by format negotiation. A 650 KB PNG re-encodes to tens of kilobytes with no visible difference. - Set
sizeshonestly. Without it the browser assumes100vwand downloads a desktop-width image for a 390px phone. With it, it picks from the generatedsrcset. - Check the quality actually served. In Next 16,
qualityon the component is ignored unless the value is listed inimages.qualitiesinnext.config.ts— the URL silently falls back to the default. It is one of several Next.js 16 changes that bite quietly. Read the renderedsrcsetand confirm theq=parameter is what you set.
Render delay: the part everyone misses
This is where LCP work usually stalls. The element has arrived and still is not painted.
Fonts. If the LCP element is text, a web font blocks it. next/font self-hosts and preloads, removing a third-party round trip, and display: "swap" paints in a fallback immediately rather than holding text invisible:
import { Inter } from "next/font/google";
export const inter = Inter({ subsets: ["latin"], display: "swap" });Hydration. If the LCP element lives inside a "use client" component that renders nothing until mounted, paint waits for JavaScript. Server Components render to HTML with no client JS at all — keep the hero on the server and push "use client" down to the interactive leaves. The general rule and its cost are in my Next.js performance guide.
Blocking CSS and scripts. A stylesheet in the head blocks rendering by design. Third-party scripts should not be in that path at all — next/script with strategy="afterInteractive" or "lazyOnload" moves them out of it. The same mechanics apply to first paint, which I worked through in how I cut First Contentful Paint.
Verify against field data, not a lab score
A Lighthouse run is a single load on one machine at one network speed. It is a useful experiment, not a measurement of your users.
Use the lab to test a change — one lever, re-run, compare — and the field to decide whether it mattered. Search Console's Core Web Vitals report and the Chrome UX Report give you the 75th percentile of real visits, which is what Google actually assesses. The two will disagree, and the field data wins.
The takeaway
LCP work succeeds when it is narrow: identify the element, split the number into four parts, and fix the part that dominates.
In Next.js that usually means three specific things — render the route statically so the HTML arrives fast, mark exactly one image priority so it is discovered immediately, and keep the hero out of any client component that would make paint wait for hydration. Everything else is tuning, and tuning the wrong sub-part is how a week disappears with the number unchanged.