Fixing Next.js LCP Image Priority with next/image

Your hero image is rendered through next/image, the file is well compressed and served in AVIF — yet the LCP element consistently paints 1.5–3 seconds after first paint because the request for it starts far too late and at the wrong network priority.

Root Cause: Default Lazy Loading Plus a Mis-Warmed Preload

next/image ships with conservative defaults: every image renders as <img loading="lazy" decoding="async"> with no fetchpriority attribute. Lazy loading is the correct default for the bulk of below-the-fold images, but it is actively harmful for the LCP element. A lazy image cannot be fetched by the preload scanner during HTML parse, because the browser does not yet know whether the image will land near the viewport — that determination requires layout, and layout requires every render-blocking stylesheet to have arrived and been parsed. The hero request therefore queues behind the entire critical CSS chain instead of alongside it, and when it finally dispatches, Chromium assigns it the Low fetch priority tier that governs LCP outcomes until an in-viewport boost promotes it mid-flight.

The priority prop reverses all three penalties at once. Next.js switches the element to loading="eager", stamps fetchpriority="high" on the <img>, and — critically — injects a <link rel="preload" as="image"> carrying imagesrcset and imagesizes attributes into the server-rendered <head>. That preload is visible to the browser in the first bytes of HTML, so the image request leaves the queue in parallel with the stylesheets rather than after them.

There is a second, subtler failure mode that survives even after priority is added: a sizes prop that does not match the rendered layout. The emitted preload’s imagesizes attribute mirrors your sizes prop, and the browser uses it to select one candidate from imagesrcset before layout exists. If sizes claims 100vw but CSS constrains the hero to 50vw, the preload warms a variant twice as wide as the one the laid-out <img> element ultimately selects. The result is the worst of both worlds: an expensive high-priority fetch of the wrong file, followed by a second fetch of the correct variant that starts as late as the unoptimized case. In DevTools this presents as two requests to /_next/image with different w= parameters — one initiated by the preload, one by the <img>. This is a framework-flavored instance of the general rule covered in preloading critical above-the-fold assets: a preload only helps when its cache key and variant selection exactly match the consumer’s.

Filter the Network panel to the optimizer route and the mismatch is unmistakable — one hero, two transfers, and the expensive one is the row the page never paints.

Stylised Network panel filtered to the Next.js image optimizer, showing the same hero fetched twice at 1920 and 1080 pixels wide because the sizes prop disagrees with the layout A drawn DevTools Network panel filtered to slash underscore next slash image. The first row is the 1920-wide variant, initiated by the preload in the document head, High priority, 210 kilobytes. The second row is the 1080-wide variant, initiated by the laid-out img element, High priority, 96 kilobytes. A summary line reads two requests, 306 kilobytes transferred, of which only 96 kilobytes is ever painted. Two cards below state the waste and the corrected sizes value. Network panel, filtered to /_next/image: one hero, two transfers sizes claims 100vw, but CSS constrains the hero to 50vw on desktop — preload and img choose different candidates Name Status Priority Initiator Size image?url=%2Fhero.jpg&w=1920&q=75 warmed by the <head> preload — never painted 200 High (index):preload 210 KB image?url=%2Fhero.jpg&w=1080&q=75 what the laid-out <img> actually selects 200 High Hero.tsx 96 KB 2 requests for one hero, 306 KB transferred, 96 KB of it ever painted 210 KB spent at High priority ahead of the variant the page needs sizes="(max-width: 768px) 100vw, 50vw" preload and img now agree on w=1080

Lazy Default vs priority: the Request Timeline

The diagram contrasts the request sequencing of a default next/image hero against the same component with priority and a correct sizes prop.

Two stacked waterfalls for the same hero component: the lazy default starts the image fetch at 1.86 seconds and reaches LCP at 3.09 seconds, while the priority prop starts it at 0.21 seconds and reaches LCP at 1.41 seconds Two waterfalls share a zero to four second axis. In the default case the document, stylesheet and application JavaScript load first and the hero image only begins transferring at 1.86 seconds, once layout has run, finishing at 3.09 seconds where LCP is marked. In the priority case the head preload starts the hero fetch at 0.21 seconds alongside the stylesheet, and LCP lands at 1.41 seconds. Same hero component, two request schedules Throttled 4G at 9 Mbps down and 150 ms RTT, App Router page, 1600 px hero through the default optimizer 0 1 s 2 s 3 s 4 s Default: no priority prop document 0.40 s app.css 620 ms app JS bundle 1.10 s hero image Low, then promoted LCP 3.09 s The hero cannot be requested until app.css lands and layout runs: first byte at 1.86 s. With priority and a matching sizes prop document 0.40 s app.css 620 ms hero image High from dispatch LCP 1.41 s The head preload dispatches the hero at 0.21 s, in parallel with app.css rather than behind it. document stylesheet app JS bundle hero, Low priority hero, High priority

Minimal Reproduction

The broken and fixed versions differ by two props. The rendered layout constrains the hero to half the viewport on desktop, which is what makes the sizes value load-bearing.

// BROKEN: lazy-loaded hero — fetch starts only after CSS + layout,
// at Low priority, and no preload is emitted into the SSR head.
import Image from 'next/image';

export default function Hero() {
  return (
    <Image
      src="/hero.jpg"
      alt="Product hero"
      width={1600}
      height={900}
      // no priority, no sizes: scheduler treats this like a footer thumbnail
    />
  );
}
// FIXED: priority emits <link rel="preload" as="image" imagesrcset imagesizes
// fetchpriority="high"> into the SSR head, so the fetch races the CSS instead
// of queueing behind it.
import Image from 'next/image';

export default function Hero() {
  return (
    <Image
      src="/hero.jpg"
      alt="Product hero"
      width={1600}
      height={900}
      priority
      // sizes must match rendered layout: the preload picks its srcset
      // candidate from this string *before* layout exists — a wrong value
      // warms a variant the <img> will never use.
      sizes="(max-width: 768px) 100vw, 50vw"
    />
  );
}

Server-render the fixed version and inspect the raw HTML response. You should find, inside <head>:

<!-- Emitted by Next.js: the imagesizes value is copied from the sizes prop,
     so preload variant selection and <img> variant selection stay identical. -->
<link rel="preload" as="image" fetchpriority="high"
  imagesrcset="/_next/image?url=%2Fhero.jpg&w=828&q=75 828w,
               /_next/image?url=%2Fhero.jpg&w=1080&q=75 1080w,
               /_next/image?url=%2Fhero.jpg&w=1920&q=75 1920w"
  imagesizes="(max-width: 768px) 100vw, 50vw">

Deterministic Fix Protocol

Each step has a concrete verification signal; do not advance until the signal is observed.

  • [ ] 1. Identify the real LCP element. Record a trace in the Chrome DevTools Performance panel, click the LCP marker in the Timings track, and confirm the attributed node is the hero <img>. Teams regularly add priority to an image that is not the LCP element (a background, a heading block) and see no movement.
  • [ ] 2. Add the priority prop to that component only. Confirm in the Elements panel that the rendered <img> now carries loading="eager" and fetchpriority="high", and that no ancestor still applies loading="lazy" through a wrapper component’s defaults.
  • [ ] 3. Set sizes to match the rendered slot. Measure the hero’s actual CSS width at your main breakpoints (Elements panel, Computed tab) and encode it, e.g. (max-width: 768px) 100vw, 50vw. An accurate sizes both shrinks the transferred variant and keeps the preload’s choice aligned with the element’s choice.
  • [ ] 4. Verify the emitted preload in the raw HTML. Use view-source (not the Elements panel, which shows the post-hydration DOM) and confirm a <link rel="preload" as="image"> with imagesrcset and imagesizes exists in <head>. If it is missing, the component rendered client-side only — move it into a server component or the initially rendered tree.
  • [ ] 5. Check the DevTools Priority column. In the Network panel, enable the Priority column and reload. The hero variant must show High from request start (no “Low → High” promotion tooltip), with the preload as Initiator.
  • [ ] 6. Confirm exactly one hero variant is fetched. Filter the Network panel to /_next/image and check that only one request for the hero’s URL appears. Two requests with different w= values means sizes still disagrees with the layout — fix the mismatch before trusting any metric.
  • [ ] 7. Re-measure LCP sub-parts. In the Performance panel’s LCP breakdown, “resource load delay” should collapse to near zero (under ~100 ms). If load delay is small but “load duration” is still large, the remaining problem is variant weight or server latency, not scheduling.

Once priority is in place, those steps collapse to three observables, and the first one that fails names the fix. Step 5 in particular is where a hint that looks correct in source can still lose: the causes of a fetchpriority="high" that does not take effect apply unchanged to the attribute Next.js stamps for you.

Decision tree for a hero that still paints late after adding priority: check the preload in view-source, then the number of optimizer requests, then the Priority column A three-step decision tree. From the symptom card, the first check asks whether the preload with imagesrcset appears in the view-source head; if not, the hero renders client-side and must move into the server-rendered tree. The second check counts optimizer rows for the hero URL; two rows with different w values means sizes disagrees with the rendered slot. The third check asks whether the Priority column reads High from request start; if it was promoted from Low, a wrapper still applies lazy loading. Clearing all three leaves load delay under 100 milliseconds, where the remaining cost is variant weight or origin latency. Three observables, in the order that isolates the cause Run these only after the priority prop is on the confirmed LCP element Hero still paints late priority prop already added 1. Does view-source show the preload with imagesrcset? No — the hero rendered client-side move it into the server-rendered tree pass 2. How many /_next/image rows carry the hero URL? Two, with different w= values sizes disagrees with the rendered slot pass 3. Priority column: High from request start, never promoted? No — Low, then promoted mid-flight a wrapper still forces loading="lazy" pass All three clear: LCP resource load delay under 100 ms what is left is variant weight or origin latency, not scheduling

Before/After Metrics

Lab conditions: throttled 4G (9 Mbps down, 150 ms RTT), mid-tier mobile CPU emulation, Next.js App Router page with a 1600 px hero behind the default image optimizer.

Metric Lazy default With priority + correct sizes Change
Hero request start 1,860 ms 210 ms −1,650 ms
Initial request priority Low High promoted at dispatch
LCP resource load delay 1,640 ms 40 ms −98%
Hero variants fetched 2 (wrong + right) 1 −1 request, −210 KB
LCP 3,090 ms 1,410 ms −54%
Lighthouse Performance 71 94 +23 pts

The double-fetch row deserves emphasis: before the sizes correction, the preload’s 1920-wide candidate cost 210 KB of high-priority bandwidth that delayed the correct 1080-wide variant — the preload was net-negative until its variant selection matched the element’s.

FAQ

Why doesn’t next/image add priority automatically to above-the-fold images?

The server render has no knowledge of the client viewport, so Next.js cannot know at build or SSR time which image will be the LCP element — an image above the fold on desktop may be three screens down on mobile. It defaults every image to lazy loading as the safe bandwidth choice, and instead emits a development-only console warning (“Image … was detected as the Largest Contentful Paint”) when its runtime check catches a lazy image becoming the LCP element. The decision stays with the developer because only the developer knows the layout.

Does the priority prop work together with the fill prop?

Yes, and the combination raises the stakes on sizes. With fill, rendered dimensions come from the positioned parent, so Next.js has no width to reason from and assumes 100vw when sizes is omitted. On a layout where the hero occupies half the viewport, that default makes the emitted preload warm a variant roughly twice as large as the one the browser needs — set sizes explicitly to the parent’s real responsive width whenever fill and priority are combined.

How many images per page should carry the priority prop?

Only the LCP candidate — or at most two to three when the LCP element genuinely differs across breakpoints (say, a mobile-only banner and a desktop hero). Every priority image emits another high-priority preload into <head>, and each one competes with critical CSS, fonts, and the true LCP resource for the first round-trips of bandwidth. A page with eight priority images has effectively re-created the unprioritized starting condition, just at a higher tier.


Related