This is the whole images block from CogniPrep's next.config.mjs:
images: {
formats: ['image/avif', 'image/webp'],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
minimumCacheTTL: 60,
qualities: [100, 90, 85, 75, 70],
},
It reads like a set of preferences. It is not. Every number in it is an allowlist entry on /_next/image, which is a public endpoint that takes url, w and q from the query string. The two size arrays are the permitted values of w, and qualities is the permitted values of q. Anything else is rejected before any work happens.
Which means the config is measurable from outside the app. Pick any optimizer URL off a live page and walk the parameter:
$ B='/_next/image?url=%2Fgames%2Fscreenshots%2Fshl-inductive.webp'
$ for q in 100 90 85 75 70 50 42 1; do
printf "q=%-4s " $q
curl -s -o /dev/null -w "%{http_code} %{size_download}\n" "https://cogniprep.app$B&w=384&q=$q"
done
q=100 200 3331
q=90 200 3001
q=85 200 2771
q=75 200 2568
q=70 200 2305
q=50 400 84
q=42 400 84
q=1 400 84
Five 200s, in exactly the order the array declares, and the 84 bytes behind each 400 are this:
Bad request
INVALID_IMAGE_OPTIMIZE_REQUEST
The same walk over w gives 16 twos and a 400 either side of the list: 16, 32, 48, 64, 96, 128, 256, 384 from imageSizes, then 640, 750, 828, 1080, 1200, 1920, 2048, 3840 from deviceSizes, and w=500 or w=4000 rejected. There is no merging or snapping to the nearest allowed value. It is a set membership test.
80 variants, 16 of them used
Sixteen widths times five qualities is 80 distinct transforms per source image, each a separate optimizer invocation and a separate cache entry. Now the other side of the ledger:
$ grep -rl "from 'next/image'" app components --include="*.tsx" | wc -l
8
$ grep -rno "quality={[0-9]*}" app components --include="*.tsx" | sed 's/.*quality/quality/' | sort | uniq -c
3 quality={85}
Eight files, nine <Image> elements, three of which set a quality. Everything else takes the Next default of 75. So of the five qualities we permit, the product asks for two, and 70, 90 and 100 exist only as extra rows in the variant matrix.
Is 85 worth having? On the image above it is 2,771 bytes against 2,568 at the default: 203 bytes, about 8%, for the three images where somebody decided the detail mattered. That is a reasonable call. The unreasonable part is qualities: [100, 90, 85, 75, 70], which looks like someone writing down the values they might want later. Trimming it to [75, 85] turns the matrix from 80 variants per image into 32, and makes an unexpected q a 400 at the edge rather than a fresh transform of a 2 MB source.
The Accept header is worth more than the quality knob
formats is the other half, and it is negotiated, not requested. Same URL, same width, same quality, three different clients:
$ for a in "image/avif,image/webp,*/*" "image/webp,*/*" "*/*"; do
printf "%-28s " "$a"
curl -s -o /dev/null -w "%{http_code} %{size_download} %{content_type}\n" -H "Accept: $a" "https://cogniprep.app$B&w=384&q=75"
done
image/avif,image/webp,*/* 200 2568 image/avif
image/webp,*/* 200 2874 image/webp
*/* 200 5534 image/jpeg
2,568 bytes, 2,874 bytes, 5,534 bytes. The source file on disk is already a .webp, and the client that announces nothing gets a JPEG at more than twice the size of the AVIF. That is the right fallback behaviour, and it is worth knowing which clients land on it: curl, a fair number of crawlers, and anything that fetches images without a real browser Accept header. The response carries vary: Accept, so the CDN keeps all three.
Choosing ['image/avif', 'image/webp'] in that order costs build-free bytes on every modern browser, and the measurement above is the argument for it: 306 bytes saved against WebP on one 384px thumbnail, multiplied by the twelve screenshots on a provider page.
One thing the config does not control
minimumCacheTTL: 60 sets how long the optimizer's own cache keeps a transform. It says nothing about what the browser is told, and the response makes that plain:
age: 1121623
cache-control: public, max-age=0, must-revalidate
x-vercel-cache: HIT
Thirteen days in the CDN, and a browser instruction to revalidate every single time. The edge is doing the work the browser could be doing for free. That mismatch between "how long we keep it" and "how long you may keep it" is the kind of thing a config key with the word Cache in it makes you assume you have handled.
Why map your own allowlist
Two reasons, and neither is theoretical. The first is cost: on a hosted optimizer you are billed per unique transform, so the width and quality arrays are a direct multiplier on a line item, and every value you leave in "just in case" is a row somebody can fill in. The second is debugging: when an image 400s in production and works locally, the fastest answer is whether the w your component computed is in the array, which is a question you can answer with the loop above in ten seconds.
See it
Open a provider page like cogniprep.app/games/shl, find any /_next/image?... URL in the Network tab, and change q=75 to q=76. The image becomes Bad request. Change it back to 85 and it loads, slightly larger. Then set the Accept header to */* with curl and watch the same URL answer as JPEG. The screenshots on the practice catalogue all go through the same four lines of config.
Top comments (0)