Most Next.js developers treat the Image component as a magic box: put a picture in, performance comes out. Then the box misbehaves — a hero image ships at 3840 pixels wide to a phone, the server's disk fills with mystery files under the build directory, or a stale image refuses to update after a deploy — and suddenly nobody knows where to look, because nobody knows what the box actually does.
I run several Next.js sites on infrastructure I manage myself, which means when the image optimizer misbehaves, I am the CDN team. This post opens the box: the exact HTML next/image renders, how the format negotiation between AVIF and WebP works, where the optimizer cache lives and how it evicts, and the four misuse patterns I keep finding in code reviews that silently throw the whole pipeline away.
It Still Renders an img Tag — the Magic Is in srcset
There is no proprietary loading mechanism. The component renders a standard img element and delegates the actual selection logic to the browser through srcset and sizes — the same responsive image primitives MDN has documented for a decade. What Next.js adds is generation: every URL in the srcset points at the optimization endpoint under /_next/image, with a width and quality baked into the query string. The candidate widths come straight from two config arrays: deviceSizes, which defaults to 640 through 3840, and imageSizes, defaulting to 32 through 384, for images smaller than the viewport.
// What you write:
<Image src={hero} alt="Dashboard" sizes="(max-width: 768px) 100vw, 50vw" />
// Roughly what the browser receives:
<img
src="/_next/image?url=%2Fhero.jpg&w=3840&q=75"
srcset="
/_next/image?url=%2Fhero.jpg&w=640&q=75 640w,
/_next/image?url=%2Fhero.jpg&w=750&q=75 750w,
/_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=1200&q=75 1200w,
/_next/image?url=%2Fhero.jpg&w=1920&q=75 1920w,
/_next/image?url=%2Fhero.jpg&w=2048&q=75 2048w,
/_next/image?url=%2Fhero.jpg&w=3840&q=75 3840w"
sizes="(max-width: 768px) 100vw, 50vw"
loading="lazy" decoding="async"
width="..." height="..." // reserved space = no layout shift
/>
// Without a sizes prop you get only a 1x/2x srcset —
// fine for fixed-width images, wasteful for responsive ones.The sizes prop is the hinge of the whole system. Without it, Next.js assumes a fixed-size image and emits a minimal 1x and 2x srcset. With it, you get the full width-descriptor srcset shown above, and the browser picks the smallest candidate that satisfies the layout — which is exactly how a phone ends up downloading the 640-pixel variant of your hero instead of the 3840 one. Also note width and height end up as real attributes: that reserved aspect-ratio box is why properly used next/image produces zero image-driven layout shift.
AVIF and WebP: Negotiated by the Accept Header
Format selection is classic HTTP content negotiation, not URL magic. The browser announces what it can decode in the Accept request header, and the optimizer answers with the best format you have enabled. The default configuration serves WebP; you opt into AVIF by listing both formats in the images config, in which case AVIF wins for browsers that support it and WebP remains the fallback.
Should you enable AVIF? The official numbers frame the trade-off: AVIF compresses roughly twenty percent smaller than WebP but takes about fifty percent longer to encode. On a CDN-fronted deployment that encode cost amortizes to nothing. On a self-hosted VPS where the optimizer shares CPU cores with your application — my situation, and the situation of most Indonesian SMB deployments I have built — every cold image now costs you more compute, and the cache stores each format separately, doubling disk usage for the same set of images. I ship WebP-only on small servers and have never regretted it.
Because negotiation happens per request header, the same URL serves different bytes to different browsers. If you put a misconfigured cache or proxy in front of the optimizer that ignores the Vary header, you can serve AVIF to a browser that cannot decode it. If you self-host behind nginx, confirm your proxy cache respects Vary Accept.
The Optimizer Cache: Where Your Disk Space Went
Every optimized variant — each width, each quality, each format — is written to disk so the next request is a file read instead of a re-encode. The mechanics worth knowing:
- Optimized images live in the cache/images folder inside your build directory. That is the mystery disk usage on your server.
- Expiry is governed by minimumCacheTTL or the upstream image's Cache-Control header — whichever is larger. Static imports are hashed and effectively cached forever, which is why they are the recommended path for local assets.
- There is no invalidation API. The docs are explicit: keep the TTL low, or be prepared to change the src or manually delete cached files when an image must change under the same URL.
- Disk growth is bounded by maximumDiskCacheSize — 500 MB by way of example — with least-recently-used eviction once the limit is crossed.
The no-invalidation point deserves repeating, because it produces the classic incident: marketing replaces banner.jpg with a new file under the same name, the deploy goes out, and the old image keeps serving until the TTL expires. The robust fix is content-hashed filenames — which static imports give you for free — so a changed image is, by definition, a new URL.
Four Ways Teams Quietly Defeat the Optimizer
All four of these pass code review constantly, because the page still looks fine. The waterfall tells the truth:
| Mistake | What it costs | Fix |
|---|---|---|
| fill or responsive layout without a sizes prop | Browser assumes the image is viewport-width at 100vw, picks a huge candidate, and your phone users download a desktop-sized file. | Write an honest sizes string matching the CSS layout, like 100vw on mobile and 33vw in a three-column grid. |
| quality 90 or higher everywhere | File sizes balloon for fidelity nobody perceives in content imagery, and each variant is encoded and cached at that cost. | The default 75 is well chosen. Restrict the accepted values with the qualities config so a stray prop cannot regress it. |
| Lazy-loading the LCP image | The default loading lazy delays your hero until layout settles — directly inflating Largest Contentful Paint by hundreds of milliseconds. | Set priority on the one above-the-fold image per page; it switches to eager loading and emits a preload hint. |
| Wide-open remotePatterns | Your optimizer becomes a free image-resizing proxy for the internet, burning your CPU and disk for arbitrary URLs. | Allowlist exact hostnames you actually serve from. Treat the optimizer endpoint as the compute resource it is. |
My Production Baseline for Self-Hosted Next.js
This is the images config I deploy on VPS-hosted projects, with the reasoning inline:
// next.config.ts — my self-hosted production baseline
const nextConfig = {
images: {
// WebP only: AVIF encodes ~50% slower and doubles the
// disk cache (each format cached separately). Enable AVIF
// when a CDN absorbs the cost, skip it on a small VPS.
formats: ["image/webp"],
// Optimized output lives in <distDir>/cache/images.
// There is NO invalidation API — keep the TTL modest.
minimumCacheTTL: 14400, // 4 hours
maximumDiskCacheSize: 500_000_000, // LRU-evicted past 500 MB
// Never proxy arbitrary URLs through your optimizer:
remotePatterns: [
{ protocol: "https", hostname: "images.unsplash.com" },
],
// Restrict the quality values the API accepts (Next 15+):
qualities: [60, 75],
},
}
export default nextConfigThe Five-Minute Image Audit
- Open the network panel, filter by images, and reload: check the actual pixel width downloaded versus the rendered size — more than a 2x ratio means a sizes problem.
- Confirm the LCP image has priority and everything below the fold does not.
- Check response headers on an optimized image: the content type should be WebP or AVIF, and Vary should include Accept.
- Look at cache/images disk usage on the server and verify your TTL and max-size settings match how often your imagery changes.
- Review remotePatterns for wildcards that turn your optimizer into a public proxy.
The Takeaway
next/image is not magic — it is three honest mechanisms stapled together: srcset generation against your configured width arrays, Accept-header format negotiation, and a disk cache with LRU eviction and no invalidation. Once you can name those three parts, every symptom maps to a cause: oversized downloads are a sizes problem, stale images are a cache-TTL problem, and a full disk is a formats-times-variants problem. The component does the right thing by default surprisingly often — but on the projects where images dominate the payload, knowing the internals is the difference between guessing and fixing.
Curl your own optimizer twice — once with an Accept header advertising AVIF and once without — and compare the content-type and content-length of the responses. Watching the negotiation happen on your own server teaches more than any diagram.
Sources and further reading

