BearLog

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.