The Problem
A landing page client pinged me about a PageSpeed regression last Tuesday. Their hero section had used a CSS background-image for as long as the site had existed. After the Next.js 16 upgrade and a Turbopack production build, mobile LCP jumped from 2.1s to 4.6s and the field data on Chrome User Experience Report dragged the whole domain into the orange band within a week.
The Lighthouse report was straightforward. The LCP element was the hero <section>, and the LCP source was the same JPG that had always been there:
Largest Contentful Paint element
<section class="hero">
Render delay: 3,820 ms (82%)
Resource load duration: 412 ms
Resource load delay: 2,940 ms
Discovery time: 2.94s after navigation start
A 2.94 second discovery delay on an image that lives in the critical render path means the browser had no idea the image existed until after CSS finished parsing. Nothing had changed about the markup or the stylesheet. What had changed was the build pipeline, and with it the resource hints Next.js emits.
Why It Happens
Browsers preload images using the lookahead preload scanner, which runs while HTML is still streaming. The scanner can find <img> tags, <link rel="preload"> directives, and srcset candidates. It cannot find images referenced from CSS, because the scanner doesn't parse stylesheets. Discovery of background-image URLs only happens after the CSS Object Model is built, the matching rules apply to a DOM node, and the layout pass demands the resource. On a fast network that costs a hundred milliseconds. On a 4G mobile connection, it costs several seconds.
This is not a new browser behaviour, but it had been masked on this site by the Next.js 15 webpack plugin emitting an automatic preload hint for any image referenced in CSS files that fell under the configured experimental.cssImagePreload. Next.js 16 removed that option when Turbopack became the default for production builds. The new bundler does not scan CSS for image references at build time, so no preload hint goes into the document head and the browser is left to discover the image the slow way.
There is no warning in the build output. The site looks identical, but the preload <link> that used to sit in the head is gone. The web.dev guide on LCP image discovery covers the underlying browser behaviour, but it cannot tell you that your bundler stopped helping.
The Fix
The right answer is to stop using background-image for above-the-fold imagery and switch to the Next.js <Image> component. Three patterns depending on how attached you are to the existing CSS.
Pattern 1: Replace the background with <Image fill priority>. This is what I do on greenfield work and what I migrated this client to. The component renders an <img> that the preload scanner can see, and the priority prop emits an fetchpriority="high" plus a <link rel="preload"> for the right candidate from the responsive set:
import Image from 'next/image'
import heroImage from '@/public/hero.jpg'
export function Hero() {
return (
<section className="relative h-[80vh] overflow-hidden">
<Image
src={heroImage}
alt="Open laptop on a wooden desk with a notebook beside it"
fill
priority
sizes="100vw"
className="object-cover -z-10"
/>
<div className="relative z-10 flex h-full items-center justify-center">
<h1 className="text-5xl text-white">Ship faster</h1>
</div>
</section>
)
}
The -z-10 on the image and relative z-10 on the content keep the layered look of a CSS background without the preload penalty. LCP on this layout for the same client dropped to 1.8s on mobile and 0.9s on desktop on the next field data refresh.
Pattern 2: Keep the background, add a manual preload. When the design system insists on CSS backgrounds because of repeating patterns or gradients layered over the image, drop a <link rel="preload"> into the document head with the correct media query. In App Router this lives in app/layout.tsx:
export default function RootLayout({ children }) {
return (
<html lang="en">
<head>
<link
rel="preload"
as="image"
href="/hero-mobile.jpg"
media="(max-width: 768px)"
fetchPriority="high"
imageSrcSet="/hero-mobile.jpg 1x, /hero-mobile@2x.jpg 2x"
/>
<link
rel="preload"
as="image"
href="/hero-desktop.jpg"
media="(min-width: 769px)"
fetchPriority="high"
/>
</head>
<body>{children}</body>
</html>
)
}
The media attribute on <link rel="preload"> is the part that matters. Without it the browser will preload both images on every device and waste bandwidth. With it the preload only fires when the matching breakpoint applies, and the preload scanner can act on it before CSS is parsed.
Pattern 3: Inline the critical CSS that references the image. A weaker option, but useful when you cannot edit the head. Make sure the CSS rule containing the background-image lives in the inline <style> block that Next.js emits at the top of the body for the route. That at least lets the CSSOM resolve the URL during HTML parsing instead of after the external stylesheet downloads:
export default function HomePage() {
return (
<>
<style>{`
.hero { background-image: url('/hero-desktop.jpg'); }
@media (max-width: 768px) {
.hero { background-image: url('/hero-mobile.jpg'); }
}
`}</style>
<section className="hero h-[80vh]">{/* ... */}</section>
</>
)
}
This still loses to Pattern 1 by about 400ms on slow connections in my testing, but it claws back roughly half the regression without changing the markup.
Verify the fix. Run Lighthouse on a real mobile profile and check the Network waterfall:
npx lighthouse https://your-site.com \
--preset=desktop \
--only-categories=performance \
--view
The hero image should appear in the initial wave of requests, ahead of any non-critical JavaScript. Discovery time in the LCP breakdown should drop to under 200ms. If it does not, the preload <link> is being added below a render-blocking script, which delays the scanner. Move the preload to the very top of <head>.
The pattern I have settled on across client work since the Turbopack switch is to never use CSS background-image for LCP candidates. The savings are too big and the workarounds are too brittle.
If your Core Web Vitals tanked after a Next.js 16 deploy, that is the kind of regression I get paid to chase down. See my services. For a related LCP regression after the same upgrade, read LCP preload wrong image fix.