Stop Over-Generating Images with next/image
Every time a new image size is requested on Vercel, Vercel transforms it and writes it to the image cache. That's a write. When the same size is served again, that's a read. If your write-to-read ratio is high, it usually means you're generating image variants that almost nobody requests: wasted transformations, wasted cache space, and higher image optimization costs.
The root cause is almost always a mismatch between the sizes Next.js is configured to generate and the sizes your layouts actually need.
How next/image Generates Variants
When a browser requests an optimized image via /_next/image, Next.js uses the w query parameter to determine what size to produce. The browser arrives at that w value by evaluating the srcset and sizes attributes on the rendered <img> element.
Next.js builds the srcset from two config arrays in next.config.js:
deviceSizes: widths used for full-viewport images (default:[640, 750, 828, 1080, 1200, 1920, 2048, 3840])imageSizes: widths used for fixed or constrained images (default:[16, 32, 48, 64, 96, 128, 256, 384])
Combined, that's 16 possible widths. Each unique combination of (url, width, quality, format) is a separate cache entry. For a site with dozens of images, each shown at different breakpoints in different layouts, the number of cache entries multiplies fast.
Next.js generates each entry exactly once on first request, then serves it from cache on every subsequent hit. If your traffic is spread thin across many variants, or if a variant only ever gets requested once and sits idle, your write-to-read ratio climbs.
Default srcset candidates (deviceSizes + imageSizes = 16 widths):
640, 750, 828, 1080, 1200, 1920, 2048, 3840 ← deviceSizes
16, 32, 48, 64, 96, 128, 256, 384 ← imageSizes
Optimized for a site with sm/md/lg/xl breakpoints and max 1280px content width:
640, 828, 1080, 1280, 2560 ← deviceSizes (1x + 2x for retina)
64, 128, 256, 320 ← imageSizes (thumbnails + card images)
The sizes Prop Is Half the Equation
Trimming deviceSizes helps, but the browser won't request the right variant unless you tell it how wide the image actually renders. That's what the sizes prop does.
Without sizes, the browser assumes the image will be as wide as the viewport (100vw). The browser picks a srcset candidate based on viewport width and device pixel ratio, which often means requesting a much larger image than necessary.
// Bad: browser assumes 100vw. On a 1440px screen this requests a ~1440w image
// even though the image is in a sidebar that's only 320px wide
<Image src="/product.jpg" width={320} height={240} alt="Product" />
// Good: browser knows the image is 320px wide regardless of viewport
<Image
src="/product.jpg"
width={320}
height={240}
sizes="320px"
alt="Product"
/>
For responsive images that change size at different breakpoints, mirror your CSS media queries exactly:
// This image is full-width on mobile, half-width on md, one-third on lg
<Image
src="/hero.jpg"
fill
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
alt="Hero"
/>
The browser evaluates these conditions left to right and picks the first match. The value you provide here directly determines which w value gets appended to the /_next/image request URL, which determines which cache entry gets written.
Aligning Your Config to Your Breakpoints
Start by auditing what widths your images actually render at. If you're on Tailwind, your breakpoints are sm: 640, md: 768, lg: 1024, xl: 1280, 2xl: 1536 by default. If your max content width is 1280px, there's no point generating a 1920px or 3840px variant for anything inside that container.
Here's what a config for a typical marketing site might look like after trimming:
// next.config.js
module.exports = {
images: {
// Only generate sizes that your layouts actually produce.
// Include 2x for retina but cap at your max content width × 2.
deviceSizes: [640, 828, 1080, 1280, 1920],
imageSizes: [64, 128, 256, 384],
},
}
If your largest content container is 1280px and you want retina support, 1920 as the ceiling is reasonable. Dropping 2048 and 3840 eliminates two of the most expensive variants. Large images take the longest to transform and consume the most cache storage. Real users at those widths are rare.
Where to Look for Wasted Variants
On Vercel, you can see image optimization usage in the Usage dashboard under your project. Look for a high transformation count relative to your traffic. If you're transforming 10,000 images per day but only serving 12,000 requests, nearly every image is being generated fresh. That's a sign the variants aren't being reused.
The specific URL pattern to audit is /_next/image?url=...&w=.... Check your logs or analytics for which w values actually appear in production. Any value that shows up rarely or only once is a candidate for removal from your config.
Raising the Cache TTL
By default, Vercel caches optimized images for the duration of the source image's Cache-Control header, with a minimum of 60 seconds (minimumCacheTTL). For images that don't change often, raise this significantly:
module.exports = {
images: {
deviceSizes: [640, 828, 1080, 1280, 1920],
imageSizes: [64, 128, 256, 384],
minimumCacheTTL: 60 * 60 * 24 * 30, // 30 days
},
}
This won't fix a misconfigured sizes prop, but it does mean that once a variant is written, it stays useful for longer, improving the write-to-read ratio over time without any changes to request patterns.
Skipping Optimization Entirely
For images that can't be meaningfully compressed — SVGs, single-pixel tracking pixels, already-optimized PNGs under a few KB — pass unoptimized:
<Image src="/logo.svg" width={120} height={40} unoptimized alt="Logo" />
This bypasses the image transformation pipeline entirely. No cache write, no transformation cost. Use it selectively; it's not a blanket solution for images that genuinely benefit from AVIF/WebP conversion or size reduction.
The Checklist
- Audit your actual breakpoints and max image widths.
- Set
deviceSizesto only include widths your layouts produce, including a 2x retina ceiling. - Set
imageSizesto match your fixed-size UI elements (avatars, thumbnails, card images). - Add a
sizesprop to every<Image>that usesfillor renders at a constrained width. - Raise
minimumCacheTTLfor static or infrequently changed images. - Mark SVGs and small fixed assets as
unoptimized.
The defaults are generous because Next.js has no way to know what your layouts look like. That's your job. Get it right, and each image variant gets written once and read many times. That's how a cache is supposed to work.
Hi! I'm Charlie, a Solutions Architect at Vercel. Opinions are my own.