Blog ·
The bug that only existed in production
Pages is the free personal page at bearlog.app/@handle — links, a contact
card, a theme, and a URL short enough to print on something. The whole point of
it is that it is fast in front of a stranger, so a Page ships no JavaScript at
all. There is a test that fails if a <script tag ever appears in one.
To get that, a Page is not a React page. It is a route handler that returns a string:
// app/[handle]/route.ts
export const dynamic = "force-static";
export const revalidate = false;
export async function GET(_req, { params }) {
const found = await resolveHandle(normalizeHandle((await params).handle));
return new Response(renderPageHtml(found.page), { headers: HTML });
}
renderPageHtml is ours. resolveHandle talks to Supabase through its own
client. Next renders it once, the CDN serves it forever, and publishing calls
revalidatePath("/@handle") to knock the old copy down.
That last part had never worked.
The report
Edit the Page, hit Publish, no errors. The editor shows the change. The public Page shows what was there before. Not for a few seconds — indefinitely.
Dev could not show me this
next dev has no CDN, no ISR and no data cache. Every request re-renders from
the database. So in development the feature was not merely working, it was
unfalsifiable: there was no cached copy to serve stale, which meant there was
no way for the invalidation to be wrong.
This is a whole category of bug, and it is worth naming. Dev/prod parity covers your code. It does not cover the caching layers that only exist once something is deployed. Anything whose failure mode is “the old answer keeps working” will look perfect on localhost.
The header that does not lie
The way in was the age response header. I published at 15:22:28 and polled
the live URL every ten seconds:
15:22:16 age=548 HIT (old content)
15:22:28 ← published
15:22:37 age=570 HIT (old content)
15:23:53 age=646 HIT (old content)
15:24:36 age=688 HIT (old content)
age climbed straight through the publish. It did not reset, it did not dip,
nothing regenerated. That is not a slow purge or a race — a purge that fired
and lost would still show up as a reset somewhere. Nothing was ever sent.
vercel cache purge fixed it every time, which confirmed the diagnosis and was
useless as a fix: it clears the CDN for the entire project because one person
changed their phone number.
Why the purge had nothing to hold on to
I lost time on the wrong theories first — a rewrite adding a second cache layer, an unapplied migration, publishing from the wrong environment. None of them survived contact with the data.
The real answer is the shape of the route. revalidatePath invalidates by
path, and a path is only meaningful to Next if Next is the thing that built what
is stored under it. This route steps outside that on both sides: the HTML comes
from our own renderer rather than the React one, and the data comes from a
Supabase client rather than the instrumented fetch. Every ordinary Next app
gets its cache entries tagged automatically because it uses both. This one uses
neither.
So the call was not failing. It was resolving a path that had nothing tied to it, and doing exactly nothing, quietly, in production only.
The fix
Stop asking the framework to infer what to invalidate, and tell it. The data read gets wrapped and tagged:
// lib/data/pages.ts
export async function resolveHandle(handle: string) {
return unstable_cache(async () => { /* the Supabase reads */ },
[handle],
{ tags: [`page-${handle}`], revalidate: false },
)();
}
and publishing purges that tag by name:
revalidateTag(`page-${handle}`, { expire: 0 });
Now there is a real cache entry with a real label on it, and a purge that names
that label. The Page is still static, still one CDN hit, still no JavaScript.
age resets on publish.
What I took from it
Framework caching is inference. It reads your code, guesses what a piece of output depends on, and labels the cache entry accordingly. The guess is good right up until you leave the paved road — and “return a string from a route handler” is off the road, however sensible the reason.
The tell is that nothing errors. An inference that comes back empty does not throw; it just declines to do anything. If you have stepped outside the abstraction on the read side, you have stepped outside it on the invalidation side too, and the only honest fix is to name the thing yourself.