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

  1. Audit your actual breakpoints and max image widths.
  2. Set deviceSizes to only include widths your layouts produce, including a 2x retina ceiling.
  3. Set imageSizes to match your fixed-size UI elements (avatars, thumbnails, card images).
  4. Add a sizes prop to every <Image> that uses fill or renders at a constrained width.
  5. Raise minimumCacheTTL for static or infrequently changed images.
  6. 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.