What next/image Actually Does: srcset, AVIF, and the Cache

Photo by TheRegisti

Photo by TheRegisti
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.
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.
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.
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:
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.
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. |
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 nextConfignext/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