Skip to content

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.

6 min read

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 foundWhen the request starts
<img> in the initial HTML, eagerDuring preload scan, before CSS
<img> in the initial HTML, lazyAfter layout, once the browser knows it is in view
Inserted by JavaScript after hydrationAfter 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

  1. Find which element is the LCP, from field data, per route.
  2. If it is an image: priority on that one image, correct sizes, and make sure nothing above it in the HTML blocks the parser.
  3. If it is text: next/font with display: 'swap' and a fallback whose metrics are close.
  4. 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.

Back to all articles