Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 4 min read

One page asks for 108 image URLs, every one says max-age=0, and the only thing we cache in a browser is four logos

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], imageSize

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

  1. 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 immutable for 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.
  2. 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 qualities array in particular looks like someone chose five tiers; nobody did.
  3. Stop reading Cache-Control from the source. The only reliable version of any caching claim is curl -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.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.