Vercel Image Optimization Limits and How to Cut Usage
A blog with ten posts on its listing page does not look like a heavy site. On Vercel's Hobby plan, image optimization is still the meter most likely to run dry first — before bandwidth, before function invocations. Hobby includes 5,000 image transformations a month, and a listing page full of small thumbnails can open up a few hundred possible cache entries without anything looking wrong on screen.
The Hobby numbers themselves are in the Hobby plan post. This post is about the mechanism: what Vercel counts, why one image turns into fifteen, and which next.config.ts values actually bring the number down. Every default quoted below was read out of the installed next@16.2.12 package, not from memory.
Three meters, not one
Image optimization is billed on three separate counters, and they behave differently:
| Meter | Hobby included | What triggers it |
|---|---|---|
| Image transformations | 5,000 / month | Every cache MISS and every STALE revalidation |
| Image cache reads | 300,000 / month | Fetching cached bytes from the global cache, in 8 KB units |
| Image cache writes | 100,000 / month | Storing transformed bytes in the global cache, in 8 KB units |
Two details matter more than the numbers. A transformation is not billed per source image — it is billed per cache miss. And a cache read is not billed on every hit: if an image was requested recently in the same region, it is served from that region and costs nothing on this meter.
That has a practical consequence. A write-heavy ratio is the bad shape. If you are creating transformations that get written once and read once, you are paying to build variants nobody reuses.
The cache key is the budget
Vercel documents the cache key for the optimization API, and it is the single most useful fact here. For a local image the key is:
- the project ID
q— the requested qualityw— the requested width in pixelsurl— for local images, the content hash of the file; for remote images, the absolute URL- the normalized
Acceptrequest header
So your transformation budget is not the number of images you ship. It is the number of distinct (image, width, quality, format) combinations that browsers actually request. Vercel's own guide puts it plainly: one hero image at 2 formats, 2 qualities and 8 widths is 32 cache entries.
Nothing in that list is the page count. Which means the fix is never "use fewer pictures" — it is "generate a smaller space of variants".
What one image actually asks for
next/image decides the width space for you, through the srcset it generates. You do not have to guess how wide that space is, because getImageProps is a public API and you can print it. I ran this inside the project root against next@16.2.12:
import { getImageProps } from 'next/image.js'
function count(label, opts) {
const { props } = getImageProps(opts)
const urls = props.srcSet.split(',').map((s) => s.trim())
console.log(label, '->', urls.length, 'entries')
console.log(urls.map((u) => u.match(/[?&]w=(\d+)/)[1]).join(', '))
}
count('cover, sizes 100vw then 768px', {
src: '/assets/cover.png', alt: '', width: 1200, height: 630,
sizes: '(max-width: 768px) 100vw, 768px',
})
count('cover, no sizes prop', {
src: '/assets/cover.png', alt: '', width: 1200, height: 630,
})
count('128px thumbnail', {
src: '/assets/cover.png', alt: '', fill: true, sizes: '128px',
})The .js suffix on the import is only needed because this runs as a plain Node ESM script, outside the bundler. The output:
cover, sizes 100vw then 768px -> 8 entries
640, 750, 828, 1080, 1200, 1920, 2048, 3840
cover, no sizes prop -> 2 entries
1200, 3840
128px thumbnail -> 15 entries
32, 48, 64, 96, 128, 256, 384, 640, 750, 828, 1080, 1200, 1920, 2048, 3840Read the third case again. A thumbnail that is never displayed wider than 128 CSS pixels offers the browser fifteen widths, up to 3840. Those fifteen are exactly the two default arrays added together: imageSizes has 7 entries and deviceSizes has 8.
The first case is almost as wasteful in a quieter way. The image is capped at 768px by its own sizes, yet 1920, 2048 and 3840 are still on the menu.
To be precise about what this costs: you are not billed for fifteen URLs. The browser picks one. But that list defines how many distinct w values are reachable, and across enough devices, pixel ratios and crawlers, a good share of them do get reached — each one a separate cache key, each first request a transformation.
Trim deviceSizes and imageSizes to your layout
These are the defaults in 16.2.12:
// next.config.ts — the defaults, for reference
images: {
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
imageSizes: [32, 48, 64, 96, 128, 256, 384],
}deviceSizes is used for images that fill a share of the viewport. imageSizes is only consulted for images that pass a sizes prop, and every value in it should be smaller than the smallest deviceSizes entry.
A content site with a reading column around 768px does not need a 3840px variant of anything, and a 128px avatar does not need seven candidate widths. Match the arrays to the widths your layout can really produce, including a 2x allowance:
images: {
deviceSizes: [640, 828, 1080, 1536],
imageSizes: [128, 256],
}That is 6 reachable widths instead of 15. The ratio is the saving, and it applies to cache writes as well as transformations.
One caution: shrinking these arrays too far means some device gets an image narrower than its slot and upscales it. Keep the largest value at roughly twice your widest rendered image.
Keep one format
The default is a single format:
images: { formats: ['image/webp'] }Adding AVIF looks like a pure win — Next.js documents it as roughly 20% smaller than WebP — but it costs 50% longer to encode and, more importantly here, Next.js caches each format separately. Two formats means two normalized Accept values, which means the width space is multiplied by two. On a plan with 5,000 transformations, paying double cardinality for 20% off the bytes is usually the wrong trade. Leave it at WebP.
Raise minimumCacheTTL, and do not trust the default
This is the one place where the documentation disagrees with itself, so check it rather than assuming. Vercel's image optimization page states the remote-image default TTL as 3600 seconds. The default in the installed package is four times that:
// node_modules/next/dist/shared/lib/image-config.js
minimumCacheTTL: 14400,Either way, a blog cover image does not change every four hours. If your images are effectively immutable, say so:
images: { minimumCacheTTL: 2678400 } // 31 daysFewer expirations means fewer STALE revalidations, and every avoided revalidation is a transformation you did not spend. The effective TTL is whichever is larger: this value, or the Cache-Control max-age from the upstream image. Static imports do better still — they hash the file contents and are cached as immutable.
The catch is real, though, and Next.js states it directly: there is no mechanism to invalidate this cache. Redeploying does not clear it. On Vercel you can purge the CDN cache manually or programmatically; on your own server you change the src or delete <distDir>/cache/images. So a 31-day TTL is right for content images and wrong for anything a user can replace in place.
Lock the qualities allowlist
In Next.js 16 qualities became a required allowlist, and the reason given in the docs is exactly the one that matters for billing: without it, anyone can request arbitrary quality values and mint transformations you never intended. The default is a single value:
images: { qualities: [75] }Keep it to one or two. A request whose quality prop is outside the list snaps to the nearest allowed value, and a direct hit on the optimization endpoint with a disallowed quality returns 400. One allowed quality is one row in the cardinality table instead of a hundred.
Close the patterns, including search
remotePatterns and localPatterns are usually treated as access control, but they are spend control too — they decide which URLs are allowed to become transformations at all. The trap is the search field:
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'images.example.com',
pathname: '/account123/**',
search: '',
},
],
}Omit search and every query string is permitted, which means the same image under a hundred cache-busting suffixes is a hundred cache keys. search: '' requires no query string at all. Next.js also recommends putting your account or bucket segment into pathname when you do not own the hostname, so a shared host cannot be used to bill transformations to your project.
Skip optimization where it buys nothing
Some images get nothing from the pipeline: SVGs, animated GIFs, and anything under about 10 KB. Optimizing them spends a transformation, a cache write and a cache read to produce a file that is not meaningfully smaller.
<Image src="/assets/logo.svg" alt="" width={120} height={32} unoptimized />Use it per image, not globally. Setting unoptimized: true in config switches the whole app to serving originals, which trades your transformation bill for a Fast Data Transfer bill — and on Hobby that ceiling is 100 GB.
What happens when you actually run out
Worth knowing before you go tuning in a panic: exceeding the included image optimization usage does not pause your deployment. Vercel pauses optimization for additional source images only. Images already in the cache keep serving, and all other traffic is unaffected. New source images return a 402 from the optimization endpoint, which fires the onError callback if you set one and renders the alt text in place of the image.
That is a good argument for writing real alt text, and it is also the failure mode to recognise: some images fine, newly added ones showing text.
Self-hosting changes the arithmetic
Off Vercel, none of these meters exist — you pay in CPU and disk instead, and the disk side has its own knob:
images: { maximumDiskCacheSize: 500_000_000 } // 500 MBWith no value set, Next.js checks available disk space once at startup and uses half of it. When the cache exceeds the limit, least-recently-used entries are evicted. The rest of what changes when you leave Vercel is in the self-hosting post — image optimization itself needs only the sharp package.
Summary
The budget is cardinality, not image count. In order of effect:
- Trim
deviceSizesandimageSizesto widths your layout can actually render. This is the big one — default config makes a 128px thumbnail reachable at 15 widths. - Keep
formatsat one entry. Two formats double everything. - Set
minimumCacheTTLexplicitly. The documented default and the shipped default disagree. - Keep
qualitiesto a single value. - Set
search: ''in your patterns so query strings cannot multiply your cache keys. - Mark SVG, GIF and tiny images
unoptimized, per image.
Then check the shape rather than the total: cache reads should grow faster than cache writes. If writes are keeping pace with reads, you are still generating variants that nobody asks for twice. And if you are tuning sizes anyway, it is worth knowing what else it affects — a missing or wrong sizes is also one of the common causes of layout shift.