next/image isn't overkill
The moment the case for next/image became unarguable for me was opening the PageSpeed Insights report for my own portfolio and seeing 31 MB of images flagged as the LCP killer. My own portfolio. The site I'd been telling clients was performance-optimized. The numbers were honest in a way I hadn't been with myself.
Every project hero. PNG. 4 to 6 megabytes each. Loaded as CSS background-image, bypassing every Next.js optimization the framework was already doing for me. The fix was three hours of work. The credibility cost of having shipped it that way in the first place is harder to recover.
This post is the diagnosis, the fix, and the case for next/image even on the surfaces where it feels like overkill.
why background-image is the trap
The seductive case for background-image is its terseness:
.hero {
background-image: url('/projects/oracle.png');
background-size: cover;
}Three lines, the image loads, the layout cooperates. What's the problem.
The problem is everything next/image does that this version doesn't.
- No format negotiation.
next/imageserves AVIF or WebP based on browser support, falling back to PNG.background-imageserves whatever file you specified. - No resolution negotiation.
next/imageserves a size appropriate for the user's viewport.background-imageserves the full file every time. - No lazy loading.
next/imagedefers offscreen images.background-imagedoesn't. - No layout-shift prevention.
next/imagereserves space via thewidth/heightprops.background-imagedoesn't (the element is sized by CSS, but the image inside it can still pop in awkwardly).
The cost of not using next/image is invisible until it shows up as a 71-second LCP on a PageSpeed report. By that point you're explaining the number to a client.
the migration
The work, end-to-end:
Step 1: convert source images. PNG → AVIF via a sharp-backed script. AVIF at quality 60, max width 1600px. The 31 MB collapses to ~1.3 MB. Single biggest win.
Step 2: replace background-image with next/image. Every place a background-image: url(...) lived, convert to a <Image fill> inside a positioned container.
// Before
<div style={{ backgroundImage: 'url("/projects/oracle.png")', backgroundSize: 'cover' }} />
// After
<div className="relative aspect-[16/9] overflow-hidden">
<Image
src="/projects/oracle.avif"
alt="Hino workflow platform — overview"
fill
sizes="(max-width: 768px) 100vw, 720px"
className="object-cover"
/>
</div>Five files in my portfolio's case. The sizes directive is the bit most engineers skip — it tells next/image which resolution to serve. Without it, next/image ships the full-size image to mobile, which defeats half the optimization.
Step 3: handle layout shift. The container needs an explicit aspect-ratio or a fixed height so the layout doesn't shift when the image loads. The aspect-[16/9] class above is the cheapest way; explicit width / height props work too.
Step 4: add a skeleton. Image loads, even AVIF ones, take some time. A placeholder shimmer prevents the blank-rectangle moment between layout and load. Optional but worth the 30 lines.
the win
The numbers before / after, measured on the actual portfolio:
| Metric | Before | After |
|---|---|---|
| Total image payload | 31.41 MB | 1.32 MB |
| Image content-type | PNG | AVIF (WebP fallback) |
| Lighthouse LCP (mobile slow 4G) | 71.6 s | ~5.8 s |
| Lighthouse Performance score (mobile slow 4G) | 35 | ~78 |
| Lighthouse Performance score (desktop) | ~65 | ~96 |
The 96-byte-reduction is the most dramatic single change to the site I've made. Faster than any code optimization, any chunk-splitting, any caching strategy. Pure asset hygiene.
when next/image is actually wrong
In the interest of being honest about the trade-offs:
Decorative tiny PNGs and SVGs. A 2KB icon doesn't benefit from format negotiation. The next/image overhead — the runtime layout management, the slight server-side cost — is wasted. Use <img> or inline SVG for these.
CSS gradients and patterns. No image, no next/image needed. CSS-native gradients ship as a few bytes of stylesheet.
Pixel-perfect critical hero images. Sometimes you genuinely need to control exactly which file loads at exactly which resolution. next/image will sometimes pick a resolution that's slightly different from what you intended. Rare but real.
Backgrounds that don't change with viewport. A repeating pattern at full opacity behind everything; background-image is fine here because there's no version of better content for mobile.
Even in those cases, the default should be next/image. The exceptions are small, well-defined, and you should be able to articulate why you're skipping the optimization. Because it feels like overkill is not a valid reason. It's a tell that the engineer doesn't know what the optimization is doing.
the meta-lesson
The 31 MB of PNGs hadn't been there because I didn't know better. They were there because the portfolio had been shipped at a stage where making it look good in dev was the priority, and optimizing for the user's network was deferred. The deferred work compounded into a publicly-visible perf failure.
The general form of the lesson:
Every "we'll optimize later" decision is a decision that you accept the cost of not optimizing for some duration. The duration is usually longer than you expect, and the cost is paid by users on slower devices and networks who you'd never have shipped to deliberately.
next/image is the default. Skip it only when you can articulate why. The cost of using it when you didn't need to: nearly zero. The cost of not using it when you did need to: the LCP that ate my mobile PageSpeed score.
