DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our pages have been in the edge cache for twelve days, and the browser is told to cache them for zero seconds

Seventy of PubTrivia's 72 public pages are prerendered at build time. The pricing page and the homepage are the two exceptions, because both read the visitor's country to choose a display currency. I went through every response header the site actually sends to find out what a second visit costs, and the answer involves three caches that do not agree with each other about anything.

Start with one page, the features index:

$ curl -sI https://pub-trivia.app/features | grep -iE 'age|cache|etag|vary|x-nextjs'
age: 1052273
cache-control: public, max-age=0, must-revalidate
etag: "jkosq8e831r6q"
vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
x-vercel-cache: HIT
x-nextjs-prerender: 1
x-nextjs-stale-time: 180
Enter fullscreen mode Exit fullscreen mode

Three numbers in there are cache horizons, and they are 1,052,273 seconds, 0 seconds and 180 seconds.

1,052,273 seconds at the edge

age is how long the CDN has been holding this exact response. A million seconds is 12.2 days, and it is the same on every prerendered page: across all 70, the median age was 1,032,207 and the highest was 1,059,852.

That number is not a cache policy, it is a deployment log. Prerendered output is immutable for the life of a build, so the age of the oldest object tells you when the build happened. Our most recent commit is dated 18 October, the deploy went out the morning of the 19th, and the cache has been serving the same bytes ever since. If you want to know when a Vercel site last shipped and you do not have access to its dashboard, subtract age from date.

The two per-request pages are the other extreme:

$ curl -sI https://pub-trivia.app/ | grep -iE 'age|cache-control|x-vercel-cache|etag'
age: 0
cache-control: private, no-cache, no-store, max-age=0, must-revalidate
x-vercel-cache: MISS
Enter fullscreen mode Exit fullscreen mode

MISS every time, no-store, age always zero, and no ETag at all. A page that chooses a price from the request cannot be shared between two visitors, so Next marks it private and the CDN is told not to keep it. Over the whole sitemap that is 70 HIT and 2 MISS, every single time I ran it.

0 seconds in the browser

max-age=0, must-revalidate means the browser may store the response and may not use it without asking first. Every navigation to a prerendered page, including a reload of a page you read a minute ago, goes back to the network.

What makes that cheap is the only conditional-request mechanism we actually have:

$ curl -s -o /dev/null -w '%{http_code} %{size_download}\n' \
    -H 'If-None-Match: "jkosq8e831r6q"' https://pub-trivia.app/features
304 0
Enter fullscreen mode Exit fullscreen mode

I did that for all 72 URLs. Seventy answered 304 with a zero-byte body. The other two are the homepage and the pricing page, which have no ETag to send, so the question cannot be asked and the full page comes back every time.

The ETags are short. Of the 70, 55 are 16 characters, 10 are 17, and 5 are 15, all of them strong (no W/ prefix) and all of them opaque: a content hash, not anything you can read a date out of.

Which matters, because the other half of HTTP's conditional vocabulary is missing entirely. Not one of our 72 pages sends a Last-Modified header, so a client that asks politely in the older dialect is simply ignored:

$ curl -s -o /dev/null -w '%{http_code} %{size_download}\n' \
    -H 'If-Modified-Since: Fri, 31 Oct 2026 00:00:00 GMT' https://pub-trivia.app/features
200 81952
Enter fullscreen mode Exit fullscreen mode

The full 82 KB comes back. There is something slightly absurd about that, because this site does know when each page was last changed and publishes it: every entry in our sitemap carries a lastmod taken from the content registry, which is the date somebody actually revised that page. That date is good enough to hand to a crawler as a freshness signal and it never appears in a response header, where a browser could use it.

180 seconds in the router

The third horizon is the one that is not HTTP at all. x-nextjs-stale-time: 180 is Next's client-side router cache, configured in next.config.mjs:

experimental: {
    staleTimes: {
        dynamic: 30,      // Dynamic pages cache for 30 seconds
        static: 180,      // Static pages cache for 3 minutes
    },
},
Enter fullscreen mode Exit fullscreen mode

Once you are inside the app, clicking between the guides and the tools and back does not necessarily touch the network, because the router holds the payload for three minutes. The thing it holds is not the HTML:

$ curl -sI -H 'RSC: 1' https://pub-trivia.app/features | grep -iE 'content-type|x-vercel-cache'
content-type: text/x-component
x-vercel-cache: HIT
Enter fullscreen mode Exit fullscreen mode

That is the React Server Components payload for the same route, 42,653 bytes against 81,952 for the HTML document, cached separately at the edge and hit just as reliably. It is also exactly what the vary header is for: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch are four Next-specific request headers, and each combination is a distinct cache entry. The first visit gets a document, every navigation after it gets a component stream, and the CDN keeps both.

So the same page has three answers to "how long is this good for": twelve days to the CDN, three minutes to our own router, and nothing at all to the browser's HTTP cache.

What compression does to all of it

While the probe was running I asked each of the 72 pages for three different encodings. For the whole site:

Encoding Total bytes
identity 5,081,702
brotli 809,654

That is 6.28 times smaller, and the ratio climbs with page size because our pages share so much structure (one header, one footer, one layout, 72 sets of prose):

Page identity brotli ratio
/ 150,476 16,801 8.96
/pricing 100,392 12,497 8.03
/quiz-questions/general-knowledge 93,831 12,818 7.32
/legal/cookies 27,052 6,074 4.45

The homepage is the biggest document on the site and compresses the hardest, which is a nice illustration of why "KB of HTML" is a bad metric on its own. It is also the one page that can never be revalidated with an ETag, so those 16.8 KB are paid in full on every visit, by every visitor, including the ones who came back twice in a minute.

What I would change

The two no-store pages are the only real cost here, and the fix is not to make them static. They are per-request because the price has to be right, and a wrong currency on a pricing page is worse than a slow one. What they could have is a stable ETag of their own: the HTML for a given country and plan set is the same HTML until we change a price, so a hash of the rendered body would let a returning visitor get a 304 instead of 16 KB. Next does not do that for a dynamic route, and doing it by hand means rendering the page to a string before answering, which costs the thing it saves.

The Last-Modified gap is cheaper to close and I am going to leave it alone too, on purpose. Adding it would mean every prerendered page claiming a modification date of the build, which is the same wrong signal we deliberately removed from the sitemap when we stopped using new Date() for all 72 entries. A content hash that changes when the content changes is a better answer than a timestamp that changes when the build changes, and we already have one.

Check it yourself

Everything above is readable from outside, with no account:

# how long since we deployed
curl -sI https://pub-trivia.app/features | grep -iE '^age|^date'

# the conditional request that saves the whole page
ET=$(curl -sI https://pub-trivia.app/features | grep -i etag | cut -d' ' -f2 | tr -d '\r')
curl -s -o /dev/null -w '%{http_code} %{size_download}\n' -H "If-None-Match: $ET" https://pub-trivia.app/features

# the two pages that are never cached, and have no ETag
curl -sI https://pub-trivia.app/ | grep -iE 'cache-control|x-vercel-cache|etag'
Enter fullscreen mode Exit fullscreen mode

Then load the pricing page in a browser, because that is the page paying for all of this: it renders per request so that the figure you read is the figure in your own currency. The free tier needs no card, so you can sign up and watch the signed-in half, which is cached by nothing at all.

Top comments (0)