The Problem
A client site has a full-bleed hero with a 1600x900 photo set as a background-image on the first section. PageSpeed reported an LCP of 3.4s on mobile, with the LCP element correctly identified as the hero section. The image was already compressed to 110KB AVIF, served from a CDN one hop away, and Cache-Control: public, max-age=31536000. The image was not the bottleneck. The discovery was.
Looking at the waterfall in WebPageTest, the image request did not even start until ~1.8s into the load. The HTML arrived at 200ms. Two render-blocking CSS files finished at 1.6s. The image kicked off 200ms after that. There is no preload scanner help for background images, so the browser literally did not know it needed the image until after CSSOM finished parsing.
I have seen this exact pattern on four client audits this month, so it is worth a clean write-up. The fix is not "convert it to an <img>" — sometimes you need the background semantics for a layered design, and just slapping fetchpriority="high" on a markup image you cannot use does not help either.
Why It Happens
The browser preload scanner runs while the HTML body is being parsed. It looks at <img>, <link rel="stylesheet">, <script>, and a few other tag-attribute combinations. It does not parse CSS. It does not know which images CSS will eventually reference. So when your LCP is a background-image, the browser cannot start that fetch until two things have happened: the CSS file containing the rule has finished downloading, and the CSSOM has been built far enough to apply that rule to the element on the page.
That is the gap on this waterfall. From a network-cost perspective the image is cheap. From a discovery-cost perspective it is the most expensive image on the page, because every byte of the render-blocking CSS has to land first.
fetchpriority="high" does not help here because there is no element to put it on. The image is referenced from a CSS rule, not a tag attribute. Chrome's LCP image discovery deep dive calls this out in the section on the preload scanner, but the fix has shifted across browser versions and a lot of advice on the web is stale.
The Fix
The fix is a <link rel="preload" as="image"> in the document head with fetchpriority="high" and imagesrcset matching the responsive breakpoints. This is the only way to start the request from HTML before CSS parses. There are three things to get right.
Step 1: Add a preload tag for the LCP background image only.
<link
rel="preload"
as="image"
href="/hero/hero-1600.avif"
imagesrcset="/hero/hero-800.avif 800w, /hero/hero-1200.avif 1200w, /hero/hero-1600.avif 1600w"
imagesizes="100vw"
fetchpriority="high"
type="image/avif"
/>
as="image" with imagesrcset and imagesizes is the responsive equivalent of srcset and sizes on <img>. The browser picks the right candidate using the same algorithm. type="image/avif" makes the browser skip the preload if it does not support AVIF, so older browsers do not waste a request. fetchpriority="high" bumps the preload above other "high" resources like fonts.
Do not preload more than one image per page. Preloading three or four images dilutes the priority and usually makes LCP worse. If a different image is the LCP on a different breakpoint, use media="(max-width: 768px)" on each preload tag to pick the right one.
Step 2: In Next.js, render the preload from the layout.
If you are on the App Router, you can use next/head-style preloads via the metadata API or render them directly:
// app/(marketing)/layout.tsx
export default function MarketingLayout({ children }: { children: React.ReactNode }) {
return (
<>
<link
rel="preload"
as="image"
href="/hero/hero-1600.avif"
imageSrcSet="/hero/hero-800.avif 800w, /hero/hero-1200.avif 1200w, /hero/hero-1600.avif 1600w"
imageSizes="100vw"
fetchPriority="high"
type="image/avif"
/>
{children}
</>
);
}
Note the camelCase: imageSrcSet, imageSizes, fetchPriority. React rewrites these to the lowercase HTML attributes. If you copy the HTML snippet from step 1 verbatim into JSX, React will warn and silently drop the attributes — which is one of the most common reasons developers think their preload "is not working".
Step 3: Verify the request starts before CSS finishes.
Open Chrome DevTools, throttle to Fast 3G, hard-reload, and look at the network waterfall. The image request should start at roughly the same time as the CSS request, both kicked off by the preload scanner reading the HTML head. If the image request still waits for CSS to finish, the preload tag is in the wrong place (must be in <head>, not body) or the attributes are mistyped.
A second verification: in the Performance panel, record a load and find the LCP marker. The "Element render delay" phase should be close to zero. If it is still 500ms+, the image is being discovered but something else is blocking the paint — usually a render-blocking script or a font swap. That is a different problem and fetchpriority will not fix it.
Step 4: Stop preloading on routes that do not use the image.
If the layout that includes the preload also wraps routes that do not show the hero, you are wasting a high-priority request on every page. Move the preload into the specific route segment that uses it, or gate it with a media query that only matches the route's intended viewport.
The Lesson
CSS background images are invisible to the preload scanner. The browser only learns about them after CSSOM parses the rule, which puts them at the back of the request queue for LCP purposes. A <link rel="preload" as="image"> with imagesrcset is the only reliable way to start the fetch from the HTML head, and the camelCase attribute names in React are an easy place to silently lose the optimisation.
If your hero image is your LCP and your waterfall shows the request starting after CSS finishes, that is the kind of Core Web Vitals fix I do for clients. See my services. For a related LCP discovery problem with markup images, read LCP preload wrong image fix.
Hero image dragging LCP past 2.5s? Let me fix it.