Vercel Image Optimization 402 Quota Exceeded Production Fix

Vercel Image Optimization returning 402 quota exceeded in production? Here is what triggers the cap and the config fix that stops the bill from rising.
PerformanceVercelImage Optimization
June 10, 20267 min read1224 words

The Problem

I got a 3am Slack ping from a client over the weekend: product images on their Next.js storefront were rendering with broken-image icons across the entire catalogue. The network tab showed every next/image request returning HTTP 402 with a small JSON body the browser was treating as a broken response:

{
  "error": "Image Optimization quota exceeded for this billing period.",
  "code": "BILLING_LIMIT"
}

Vercel hard-stops image optimization when the per-month transformed-image quota runs out on the project's plan. Once it stops, no new transformations are served. Previously-cached variants still render, so the site looks partially broken: every product the catalogue had added in the last week and every viewport size that had not been generated before showed a blank placeholder. Old SKUs at common breakpoints looked fine.

The kicker was the bill. The same project had stayed inside the Pro quota for six months with no growth in the catalogue. Something in our recent deploy was generating an order of magnitude more transformations than the previous month, and we had not noticed because the bill is itemised at month-end.

Why It Happens

Vercel meters image optimization on unique source-URL plus transformation tuples. Every distinct combination of source image, width, quality, and format counts as one transformation, billed once and cached afterward. The Hobby tier ships 1,000 per month; Pro ships 5,000. Hit the ceiling and you get the 402 until the billing period rolls over or you raise the spend cap.

Three patterns blow through the quota without anyone noticing until the page breaks.

First is the sizes attribute. A responsive image with sizes="(max-width: 768px) 100vw, 50vw" and an <Image> whose srcSet lists every breakpoint in images.deviceSizes generates one transformation per width per format per quality. The default deviceSizes array has eight widths, and combined with AVIF and WebP that is sixteen tuples per source image per quality level. Each new product image creates sixteen new tuples on first view.

Second is dynamic query strings. If your CMS appends a ?v= cache-buster to image URLs, every version of the same image counts as a new source identity. We had switched from a content hash to a timestamp the week before the bill spike, and every save in the CMS was minting a fresh URL.

Third is the quality prop. Defaulting to quality={75} and overriding it to quality={90} on hero images is fine. Passing a quality value computed from connection speed or user preference multiplies the matrix by however many distinct quality numbers the code can emit. We had introduced an effectiveType-aware quality the same week and were emitting six different values.

The Vercel image optimization docs describe the quota mechanics, but the line between "responsive images done right" and "quota explosion" is one config away.

The Fix

Three changes. Cut the deviceSizes matrix, stabilise the source URLs, and cap the quality values. Each is independent and each cuts the unique-tuple count.

Step 1: Trim deviceSizes to what you actually serve. Vercel's default array assumes you support every device width from 640 to 3840. Most catalogue pages render at four or five fixed sizes. Lock it down in next.config.ts:

import type { NextConfig } from 'next'

const config: NextConfig = {
  images: {
    deviceSizes: [640, 828, 1200, 1920],
    imageSizes: [128, 256, 384],
    formats: ['image/avif', 'image/webp'],
    minimumCacheTTL: 60 * 60 * 24 * 30,
  },
}

export default config

Going from eight widths to four halves the tuples per image. The 30-day minimumCacheTTL keeps already-transformed variants warm so a redeploy does not re-bill them. Check your actual breakpoints in the design system before you cut — removing 828 will hit iPhone Plus users with a 1200-wide image, which costs bandwidth but not quota.

Step 2: Stabilise the source URL. If your CMS or media library is appending a query parameter to bust client caches, swap it for a content hash in the path or strip it before passing to <Image>. The query string is part of the source-URL identity Vercel hashes.

// Wrong: every save in the CMS mints a new identity for the same image.
<Image src={`${product.image}?v=${Date.now()}`} alt={product.name} fill />

// Right: pass the immutable CDN URL, let the Vercel cache key handle freshness.
<Image src={product.image} alt={product.name} fill />

If you genuinely need versioning so editorial overrides flush the cache, append the hash of the file bytes, not a timestamp:

const versioned = `${product.image}?v=${product.imageHash}`

The hash only changes when the bytes change, so reuploads of the same file do not multiply tuples.

Step 3: Cap the quality prop to a small set. Each distinct quality value is a separate tuple. If you accept arbitrary quality from data or compute it on the fly, snap it to a fixed ladder:

const QUALITY_LADDER = [60, 75, 90] as const

export function snapQuality(input: number): (typeof QUALITY_LADDER)[number] {
  return QUALITY_LADDER.reduce((best, q) =>
    Math.abs(q - input) < Math.abs(best - input) ? q : best,
  )
}

Then every <Image> that passes a dynamic quality routes it through snapQuality. Three values stay well inside the cap. Letting components pass quality={82} and quality={84} doubles the cost of those breakpoints for no visible difference.

Verify the savings. Vercel's project usage dashboard shows daily image optimization counts. Deploy the three changes, give it 24 hours, and compare. If the line does not bend, query the image-optimization-events log, group by source URL, and sort by count. The top result is almost always a cache-buster you missed or a fallback image served from a different origin than the rest of the catalogue.

For a temporary unblock while you ship, raise the spend cap in billing settings. That lifts the 402 and bills the overage. Do not leave it there. It hides the underlying tuple growth.

One more pattern worth checking. Every Vercel preview deployment counts against the same project quota and generates fresh transformations on whatever images the reviewer loads. Disable image optimization on previews by branching on VERCEL_ENV:

images: {
  unoptimized: process.env.VERCEL_ENV === 'preview',
  // ...rest of the config
}

That cuts preview-driven quota burn to zero with no visible difference on previews no end user sees.

The Lesson

Vercel image optimization quotas blow up on three axes: width matrix, source-URL stability, and quality variance. Trim deviceSizes to what your design actually renders at, stabilise source URLs so the same image keeps the same identity across CMS saves, and snap quality to a small ladder. The 402 is downstream of one of those three. Fix all three and the bill stops being a surprise.

If your Vercel costs jumped after a release and you need a hand finding the leak, that is the work I do. See my services. For a related image-loading regression after a Next.js upgrade, read Next.js image remotePatterns wildcard broken.

Stuck on Vercel image costs? Get it shipped.

Back to blogStart a project