CogniPrep's next.config.mjs configures images like a project that has thought about caching:
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],
}
Then I measured the live deployment, and almost none of those numbers describe what happens.
What one page actually requests
A provider page carries 12 card screenshots. Count the optimizer URLs in its HTML:
curl -s https://cogniprep.app/games/pymetrics > page.html
grep -o '/_next/image?url=[^" ]*' page.html | sort -u | wc -l # 108
grep -o 'q=[0-9]*' page.html | sort | uniq -c # 120 q=75
108 distinct image URLs for 12 images, which is 9 srcset widths each: 384, 640, 750, 828, 1080, 1200, 1920, 2048 and 3840. And one quality. Every q= on the page is 75, so four of the five configured qualities have never been requested by anything. qualities is an allowlist, not a plan: it says what the optimizer will answer, and the components decide what to ask for. Ours all take the default.
Multiply by content negotiation and it gets worse. The optimizer answers vary: Accept and we enabled two formats, so each of those 108 URLs can exist as AVIF, WebP or JPEG. Same page, same 12 pictures, up to 324 stored objects.
# No Accept header: you get JPEG
curl -s -o /dev/null -D - "https://cogniprep.app/_next/image?url=%2Fgames%2Fscreenshots%2Fpm-keypress.webp&w=384&q=75" | grep -i content-type
# content-type: image/jpeg 2079 bytes
# Modern browser Accept: you get AVIF
curl -s -o /dev/null -D - -H "Accept: image/avif,image/webp,*/*" "https://cogniprep.app/_next/image?url=%2Fgames%2Fscreenshots%2Fpm-keypress.webp&w=384&q=75" | grep -i content-type
# content-type: image/avif 1273 bytes
The source .webp on disk is 3,728 bytes, so the AVIF re-encode at 384px is a third of it. That part is working exactly as advertised.
What the browser is told
This is the part that surprised me. Five things in front of this site cache, and here is the Cache-Control each one sends to the client, measured today:
| What | Cache-Control the client receives | Edge status |
|---|---|---|
/logos/hsbc.svg |
public, max-age=31536000, immutable |
HIT |
/_next/static/chunks/*.js |
public,max-age=31536000,immutable |
HIT |
/ (prerendered page) |
public, max-age=0, must-revalidate |
HIT, age: 2141
|
/_next/image?... (optimized) |
public, max-age=0, must-revalidate |
MISS, then HIT |
/games/screenshots/*.webp (raw) |
public, max-age=0, must-revalidate |
MISS |
/opengraph-image.jpg |
public, max-age=0, must-revalidate |
HIT |
/api/games/population-stats/<id> |
public |
STALE, age: 403
|
Everything in the bottom half of that table is uncacheable by a browser and heavily cached at the edge. x-vercel-cache: HIT with age: 2141 on a response that tells the browser max-age=0 is not a contradiction: the CDN is honouring instructions the client never sees, and the client is told to re-ask every time. For a visitor on a fast connection to an edge node that is nearly free. On a phone on mobile data it is a round trip per image per navigation.
The only assets this site genuinely caches in a browser are the ones with an explicit rule in next.config.mjs:
{ source: '/logos/:path*', headers: [{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' }] },
{ source: '/landing/:path*', headers: [...same...] },
{ source: '/favicon.ico', headers: [...same...] },
Three patterns. Four logos above the fold and a handful of landing images. Not the 372 game screenshots, which are the only images most visitors ever scroll past.
The API cache is three layers deep and the header is one word
The population-stats endpoint is the clearest case of written intent versus delivered header. The route sets this:
headers: { 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600' }
What arrives is cache-control: public. The CDN consumed s-maxage and stale-while-revalidate and did not forward them, which is defensible (they are directives for a shared cache, and it is the shared cache) and also means a client-side reader cannot tell how fresh the data is meant to be.
Underneath the route there are two more layers: the repository wraps the query in unstable_cache with a 300 second revalidate and a tag the nightly cron invalidates explicitly, and a React cache() wrapper deduplicates within a single render. So the same number is cached for one render, for five minutes, until a cron says otherwise, and at the edge with a stale window. You can watch the outer one:
curl -s -D - -o /dev/null https://cogniprep.app/api/games/population-stats/pm-keypress | grep -iE 'cache|age'
# cache-control: public
# age: 403
# x-vercel-cache: STALE
age: 403 against s-maxage=300 is stale-while-revalidate doing its job: the request was answered from a 403-second-old copy while a fresh one was fetched behind it. Run it twice a minute apart and you will see the age reset.
The three things I would change, in order
-
Put the screenshots in the immutable list. They are the heaviest repeated asset and they currently cost a conditional request each. The reason they are not there already is that their filenames are not content-hashed, so
immutablefor a year would make a re-capture invisible. The honest fix is content-hashed filenames first, header second, and until then the number to watch is how many of those 108 requests are 304s. -
Trim the config to the truth. Five qualities where one is used, and sixteen widths where nine appear, is not harmful, but it reads as a decision when it is a default. The
qualitiesarray in particular looks like someone chose five tiers; nobody did. -
Stop reading
Cache-Controlfrom the source. The only reliable version of any caching claim iscurl -D -against production. Every number in the table above differs from what the file it came from led me to expect.
See it
Every command here runs against the live site, so you can check the whole table yourself: the provider pages are the image-heavy ones (Pymetrics has the 12 screenshots and 108 URLs), the pricing page is the smaller test case, and the population-stats endpoint above answers without authentication so the stale-while-revalidate behaviour is visible from a terminal.
Top comments (0)