DEV Community

Daniel Pertu
Daniel Pertu

Posted on

74 of our 76 public pages come from the cache, and one import decides the other two

Nakodo has 76 public pages: 13 hand-built ones such as
How it works, Pricing
and About, plus 63 content pages generated from one
registry, like the guides index and
the glossary. Every one of them is in
the sitemap.

I assumed they were all static. Then I curled them.

The sweep

$ curl -s https://nakodo.app/sitemap.xml \
  | grep -o '<loc>[^<]*</loc>' | sed 's|</*loc>||g' > urls.txt
$ wc -l < urls.txt
76
$ while read -r u; do
    curl -sI "$u" | tr -d '\r' \
      | awk -v u="$u" 'tolower($1) ~ /^(cache-control|age|x-vercel-cache):/ {print u, $0}'
  done < urls.txt > headers.txt
Enter fullscreen mode Exit fullscreen mode

Two answers, and nothing in between:

Pages x-vercel-cache cache-control
74 HIT public, max-age=0, must-revalidate
2 MISS private, no-cache, no-store, max-age=0, must-revalidate

The two are / and /pricing. They are MISS on every request, not on the first
one. The other 74 have never been anything but HIT while I have been watching.

What the age header says

age is how long the edge has been holding its copy. On the 74 cached pages it
ran from 518,187 to 590,545 seconds, which is 6.0 to 6.84 days:

$ sort -k4 -n headers.txt | awk '$2 == "age:" {print $1, $3}' | tail -1
https://nakodo.app/guides/instagram-and-tiktok-disclosure-rules 590545
Enter fullscreen mode Exit fullscreen mode

Our last deploy was the commit at 2026-10-28 15:05 UTC. 590,545 seconds before
the sweep is 2026-10-28 15:07 UTC, two minutes after it. So the oldest cached
copy of a page is as old as the build that produced it, and the edge has not had
to ask for it again since.

The age values fall into two groups, around 590,000 and around 518,000, a day
apart. That is not two deploys. It is when each page was first asked for after
the one deploy: the pages in the second group had no visitor and no crawler for
a day, so the edge had nothing to keep until then.

The one import

The split is not a config decision. No page in the marketing routes exports
dynamic at all: the only one in the whole route group is force-static on the
blog's RSS feed. It comes from one 23-line module that reads a request header:

// src/lib/pricing/request.ts
const BOT = /bot\b|bot\/|crawler|spider|slurp|facebookexternalhit|whatsapp|headless|lighthouse|vercel/i;

export async function requestCountry(): Promise<string | null> {
  const country = (await headers()).get("x-vercel-ip-country");
  return country && /^[A-Za-z]{2}$/.test(country) ? country.toUpperCase() : null;
}

// Crawlers get pounds: they're placed wherever their servers are, and search
// results should show the base price, not a datacentre's currency.
export async function displayCurrency(): Promise<Currency> {
  if (BOT.test((await headers()).get("user-agent") ?? "")) return BASE_CURRENCY;
  return currencyForCountry(await requestCountry());
}
Enter fullscreen mode Exit fullscreen mode

headers() is a dynamic API. Awaiting it opts the page out of prerendering, and
anything that renders per request cannot be held at the edge. Grep says exactly
which pages did that:

$ grep -rln "displayCurrency" "src/app/(marketing)"
src/app/(marketing)/page.tsx
src/app/(marketing)/pricing/page.tsx
Enter fullscreen mode Exit fullscreen mode

Two files. Two MISSes. The 63 content pages never show a plan price, so they
never import it, so they are all prerendered without anybody deciding that they
should be.

Worth noticing in the same file: the pricing page's metadata is not per
request. Its description is built with BASE_CURRENCY, because metadata is what
a crawler indexes and a search result should not quote a datacentre's currency.
The body of the page varies, the head does not.

What the two cost

Time to first byte, three runs each, from a laptop in Europe:

Page TTFB
/guides 0.095, 0.103, 0.089
/about 0.103, 0.090, 0.094
/how-it-works 0.086, 0.099, 0.082
/ 0.279, 0.288, 0.315
/pricing 0.253, 0.254, 0.237

Three times the wait, and the response headers say where it goes:

$ curl -sI https://nakodo.app/about   | grep -i x-vercel-id
x-vercel-id: fra1::hkzrs-...
$ curl -sI https://nakodo.app/pricing | grep -i x-vercel-id
x-vercel-id: fra1::iad1::bh4dk-...
Enter fullscreen mode Exit fullscreen mode

A cached page stops at the Frankfurt edge. A dynamic one is handed from
Frankfurt to the function region in Virginia and back. The 170 extra
milliseconds are mostly that hop, not the render.

Why we are keeping it

Three ways out, and we rejected all of them.

Render the base price and swap it in the browser. This is the usual answer
and it breaks the rule the pricing work is built on: the figure in the HTML has
to be the figure Stripe charges. A client-side swap means the first paint is a
price nobody is going to be charged, and a reader with JavaScript off, or a
crawler, never gets the second one.

Cache per country. The response would have to vary on the country header
and on the user agent, because of the crawler branch above. A cache keyed on
user agent is not a cache. With 23 currencies and every bot string in the world,
the hit rate would be a rounding error on the two pages that get the most
traffic.

Split the page. Prerender everything above the plan cards, and make the
cards a dynamic island. This one is real and we may do it. It is also the only
option that adds a second rendering mode to a page that currently has none, and
the plan cards are near the top of both pages, so the part worth caching is
small.

The honest version is that two server renders on two pages is a price we can
pay at our traffic, and the thing it buys, nobody ever seeing a price they will
not be charged, is worth more than 170 milliseconds.

Check it yourself

$ curl -sI https://nakodo.app/pricing | grep -iE 'cache-control|x-vercel-cache|age'
cache-control: private, no-cache, no-store, max-age=0, must-revalidate
x-vercel-cache: MISS

$ curl -sI https://nakodo.app/guides  | grep -iE 'cache-control|x-vercel-cache|age'
cache-control: public, max-age=0, must-revalidate
age: 590545
x-vercel-cache: HIT
Enter fullscreen mode Exit fullscreen mode

If the second age is a large number when you read this, the page you just
fetched was rendered once, days ago, and has been answering from a machine near
you ever since. If you want to see the dynamic half work, open
the pricing page through a VPN in another country
and watch the currency follow it, while
the guides look identical from everywhere.

Still open

Two things this sweep left on the list. The function region is in Virginia while
most of our visitors are not, so the 170 milliseconds is a setting we have not
touched rather than a law of physics. And nothing in the test suite asserts that
a page is prerendered, which means the next component that reads a header can
quietly move a page from the first table row to the second, and the only way we
would find out is by curling it again.

Top comments (0)