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.
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.
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 addpriorityto an image that is not the LCP element (a background, a heading block) and see no movement. - [ ] 2. Add the
priorityprop to that component only. Confirm in the Elements panel that the rendered<img>now carriesloading="eager"andfetchpriority="high", and that no ancestor still appliesloading="lazy"through a wrapper component’s defaults. - [ ] 3. Set
sizesto 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 accuratesizesboth 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">withimagesrcsetandimagesizesexists 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
Highfrom 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/imageand check that only one request for the hero’s URL appears. Two requests with differentw=values meanssizesstill 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.
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
- Next.js Resource Loading Optimization — the parent section covering fonts, scripts, and route-level loading in Next.js
- Choosing the Right next/script Loading Strategy — the companion deep-dive for third-party script scheduling in the same stack
- How Browser Fetch Priority Affects LCP — the engine-level mechanics behind the priority tiers this fix manipulates