D
P
0
← All articles Baca dalam Bahasa Indonesia

Caching & CDN: Deployed, but Nothing Changed

`revalidateTag` Fires But the Listing Page Stays Stale: Prerendered Pages Need Their Own `revalidatePath`

· · 8 min read
`revalidateTag` Fires But the Listing Page Stays Stale: Prerendered Pages Need Their Own `revalidatePath`

The complaint that arrived on 5 June 2026 never used the word cache. It was about a color picker that would not propagate. The site is editorial, its content lives in Sanity, and the front end is Next.js with ISR.

What made that complaint pleasant to chase is that the symptom only held in half the places. Edit an author profile and the detail page at /writer/{slug} changed at once. The listing page at /contributors did not. That listing page went on serving old HTML.

The case notes also record one wrong turn right at the start. The first guess landed on the Cloudflare cache, which was not the layer that had gone stale.

One edit, two fates

To be sure the symptom repeated and was not a single coincidence, I changed highlightColor to red, then to white. The detail page changed every time. The listing page lagged every time.

That repetition matters because it also throws out one big possibility. If the webhook were not arriving, or if authentication were failing, no page would change at all. Only one page out of two changed, so the webhook arrived and the handler ran to completion. What was incomplete is the work that handler does.

Tags invalidate data, paths invalidate pages

The revalidate route in this project does two different things for every incoming event. One revalidateTag call that applies across the board, and one or more revalidatePath calls picked by document type.

const body = await req.json();
 
// runs for EVERY document type that sends an event
revalidateTag(body._type, "max");
 
// path branches only exist for types already registered here
switch (body._type) {
  case "article": /* ... */ break;
  case "category": /* ... */ break;
  case "staffProfile":
    revalidatePath(`/writer/${slug}`);
    break;
  // writerProfile: new document type, no branch here yet
}

The writerProfile type was added to the schema later, as the human counterpart to the staffProfile that had been there longer. The schema was updated, the page was built, the query ran. What did not get updated is the switch above. staffProfile kept its path branch, writerProfile never got an equivalent.

So every time a profile or its accent color was edited, only half of it actually executed:

Edit highlightColor on a writerProfile document
 
fires     revalidateTag("writerProfile", "max")   data-layer cache for getWriterProfiles invalidated
silent    revalidatePath("/contributors")
silent    revalidatePath("/writer/editor-a")

That is where the two pages part ways. /writer/{slug} is server-rendered on demand, so once its data-layer cache is invalidated the next request rebuilds the page from fresh data. /contributors is statically prerendered at build time. Its HTML is already finished and stored, and invalidating the data cache does not by itself throw that finished HTML away.

Tag invalidation is necessary here, but it is not sufficient. Statically prerendered pages need explicit path-based revalidation to flip from stale to fresh quickly.

The fix

The fix is one commit, and its shape is as simple as it looks. Both profile types were folded into a single branch that invalidates both URLs where they show up.

case "staffProfile":
case "writerProfile":
  revalidatePath("/contributors");
  revalidatePath(`/writer/${slug}`);
  break;

staffProfile was pulled in even though it already had a branch of its own, because that old branch only touched the detail page and skipped the very same listing page.

After that fix, explicit path branches exist for seven document types: article, category, staffProfile, writerProfile, termEntry, assetPage, and specialEdition. Everything else, including every configuration singleton such as layoutSettings, menuConfig, bannerConfig, serviceSettings, and siteConfig, gets tag invalidation only.

The rule I put in place afterwards has two sides, and the second one is the easiest to forget. First, every new Sanity document type that has a public listing page or a slug-based detail page has to update the revalidate route in the same PR. Second, the webhook filter in Sanity Studio also has to genuinely fire for that type. A perfect route is worth nothing if the events for the new type are never sent in the first place.

Two follow-up complaints that turned out not to be this bug

Two months later, two complaints came in that looked similar but had entirely different causes. Both are worth writing down, because both nearly triggered code changes that were never needed.

On 2 August 2026 the client hid the featuredStrip section on the homepage, published, and 19 seconds later reported: "I already hid it, so why is it still there". Practically instant.

10 curls with a cache-buster   ->  0 of 10 still showed the section
clean Playwright context       ->  agreed, the section really was gone
homepage                       ->  cf-cache-status: DYNAMIC, never edge-cached

The server was already correct. What had gone stale was the client's own browser cache. Cloudflare was innocent too, because the homepage reports DYNAMIC and never enters the edge cache at all. The rule I took from that: do not start deploying a fix for an "it had no effect" complaint before the gap between curl and browser has actually been measured.

Purging the CDN while the origin is still stale only makes the staleness last longer

A day later, on 3 August, the second incident arrived. I patched the human-editor-b document straight into the dataset, and both /writer/editor-b and /contributors kept serving the old byline. I ran a Cloudflare purge, and nothing changed, because Cloudflare was never the stale layer.

These three headers are what finally showed who was holding on to the old version:

cf-cache-status: MISS      <- CF already going to origin, the purge worked
x-nextjs-cache: HIT        <- the ORIGIN is serving a cached render
x-nextjs-prerender: 1      <- and this is an ISR page entry

The order of events is what stretched everything out:

1. dataset patched               origin still serving the pre-patch render,
                                 its fetch cache had about 1h left
2. Cloudflare purge run          CF went to origin and got the STALE render
3. CF stored that stale render   for the full 2h edge TTL on /writer/*

The math is easy to redo. Without the purge, the origin recovers by itself once that remaining fetch TTL of roughly 1 hour rolls over, and that is exactly what eventually happened. With a purge mid-flight, the stale copy is pinned at the edge for a full 2 hours counted from the moment of the purge. That purge ran after the dataset patch, so the origin had at most an hour of TTL left by then, and the extra staleness is at least an hour.

The net effect of that purge was not to end the staleness but to extend it. So the rule becomes: confirm the origin is fresh first, then purge. A purge in mid-flight just re-pins the old copy.

An earlier reading in the case notes needs straightening out here as well. x-nextjs-prerender: 1 does not mean the page is frozen forever. These routes still time-revalidate from the fetch-level revalidate, even with no export const revalidate anywhere. The origin does refresh itself, and on top of that wrong premise the case notes mention an export const revalidate that was nearly shipped for nothing.

The diagnostic order, cheapest first

After those two incidents the order of checks became fixed, arranged from the cheapest:

  1. The bare URL against the URL with ?cb=<random>. This separates the edge's answer from the origin's answer.
  2. x-nextjs-cache. This answers whether the origin itself is serving a cached render.
  3. curl against browser. This answers whether the stale copy sits on the client side.

The route handler or a new Sanity webhook filter only deserves suspicion once ?cb= comes back stale too.

curl -sI "https://situs-klien.example/writer/editor-b?cb=8412" | grep -i cf-cache-status
# cf-cache-status: MISS      the CF rule on this site does not ignore query strings
 
curl -sI https://situs-klien.example/contributors | grep -i cf-cache-status
# cf-cache-status: DYNAMIC   never edge-cached, so it makes a handy control

The quick version, the one to use while standing up: curl the production page after an edit. If the HTML reflects the change within about 10 seconds but the visible page does not, suspect the Cloudflare cache or the browser cache. If the HTML is stale for minutes as well, then the route handler is the one that deserves suspicion.

One limitation is worth knowing before reaching for a shortcut: production cannot be force-busted from local. The webhook secret differs per environment, so a forged webhook from your own machine comes back 401.

The rules that stuck