next/image Will Not Fix Your LCP
The component optimises bytes. LCP is decided by when the request starts, and the usual causes of a slow one are discovery order, a lazy hero and a font that blocks the paint.
Teams arrive having already swapped every <img> for next/image, and the
LCP did not move. That is the expected outcome, and it is worth understanding
why, because the same reasoning points at what actually would have worked.
next/image optimises the payload: a smaller format, the right dimensions
for the device, no layout shift. LCP measures the timing of the largest
paint. Bytes are one input to that, and usually not the binding one.
What decides when the request starts
The browser can only fetch what it has discovered. For the hero image, there are three possibilities, and they are an order of magnitude apart:
| How the image is found | When the request starts |
|---|---|
<img> in the initial HTML, eager | During preload scan, before CSS |
<img> in the initial HTML, lazy | After layout, once the browser knows it is in view |
| Inserted by JavaScript after hydration | After the bundle downloads, parses and runs |
next/image lazy-loads by default. So the common migration turns an eager
hero into a lazy one, delays its request until after layout, and makes LCP
worse while every Lighthouse image audit turns green.
The fix is one prop:
import Image from 'next/image';
export function Hero() {
return (
<Image
src="/hero.jpg"
alt="..."
width={1600}
height={900}
priority // eager + fetchpriority="high" + a preload hint
sizes="100vw"
className="w-full h-auto"
/>
);
}priority on exactly one image per route - the one that is the LCP element.
Putting it on six images is the same as putting it on none, because everything
competes for the same connection.
The sizes mistake that costs more than the format
sizes tells the browser which candidate from srcset to pick, and it is
evaluated before layout. Get it wrong and you download a 1600px image for
a 400px slot, or - worse for quality - a 400px image for a full-bleed banner.
The default when you omit it is 100vw, which is right for a full-width hero
and wrong for everything else:
// A card image in a three-column grid.
<Image
src={post.cover}
alt=""
width={800}
height={450}
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
/>That single attribute is usually worth more than the AVIF conversion, because it changes which file is requested rather than how many bytes that file has.
When the LCP element is text
On a content site it usually is, and then the image work is beside the point. The heading cannot paint until its font is resolved, so the chain is: HTML → CSS → font file → paint.
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap', // paint in the fallback, swap when the file lands
variable: '--font-sans',
});next/font self-hosts the file and emits a preload, which removes the
third-party connection from the chain. display: 'swap' is what stops the
heading waiting at all - the cost is a visible swap, which you reduce by
picking a fallback with similar metrics rather than by blocking the paint.
The other half is the fallback stack. font-family: var(--font-sans), sans-serif
lets the browser paint immediately in a font whose metrics you did not choose;
naming a close system font instead makes the swap far less noticeable.
Measuring the right thing
Lighthouse reports lab LCP on a cold, throttled load. Field data reports what your visitors experienced, including cache hits and real devices. They disagree constantly, and the field number is the one that matters.
'use client';
import { useReportWebVitals } from 'next/web-vitals';
export function Vitals() {
useReportWebVitals((metric) => {
if (metric.name !== 'LCP') return;
// The element, not just the number - "LCP is 3.1s" is not actionable,
// "LCP is 3.1s and it is the hero image" is.
navigator.sendBeacon('/api/vitals', JSON.stringify({
value: metric.value,
element: metric.attribution?.element,
url: location.pathname,
}));
});
return null;
}Log the element. Half the performance engagements we run start by discovering
that the LCP element is not what the team assumed - a cookie banner, a hidden
<h1>, an empty container that happens to be large.
The full ordering of what to fix, and in what sequence, is in Core Web Vitals in Next.js: what actually moves the numbers.
The short version
- Find which element is the LCP, from field data, per route.
- If it is an image:
priorityon that one image, correctsizes, and make sure nothing above it in the HTML blocks the parser. - If it is text:
next/fontwithdisplay: 'swap'and a fallback whose metrics are close. - Only then think about format and quality.
Steps one to three are where the seconds are. Step four is where the audits are, which is why it is the one that gets done first.
