Skip to content
tecminds

revalidateTag Did Not Bust the Edge: Why Our Next.js Page Served Yesterday’s Data for 12 Minutes

After a Stripe price write we called revalidateTag and expected the product page to flip. Vercel’s Data Cache and a CDN-cached RSC payload kept serving the old 200 for about twelve minutes. This is the trap of mixing fetch cache tags, unstable_cache / 'use cache', and a layout that never joined the same tag — plus the checklist we now run before we trust a hard refresh.

TTobias LüscherCo‑Founder · TecMinds2026-09-01 · 8 min read

revalidateTag Did Not Bust the Edge: Why Our Next.js Page Served Yesterday’s Data for 12 Minutes

The most expensive cache bug is the one that looks fixed on your laptop. We updated a Stripe price from the webhook, wrote the new amount into Postgres, called revalidateTag('product'), and watched the admin UI flip. Support sent a screenshot of the public product page still showing yesterday. A hard refresh on our machine showed the new price. An incognito window in another city, and the status chip in the layout, did not. Twelve minutes later the edge served the new payload on its own, as if we had never called anything. This is the writeup of revalidateTag not busting the edge — mixing fetch cache tags, unstable_cache / 'use cache', and a layout that never opted into the same tag — plus a 200 the CDN treated as still fresh.

The write itself was the same shape we already treat as a money path. A Stripe event lands, we claim event.id before the side-effect, persist the price, then tell Next.js the tagged reads are dead. The docs read as if that last call is enough. It is enough for one cache. Production on Vercel is three.

The Call That Only Punched One Layer

App Router gives you three places a product can live, and they do not share a brain.

The page did the textbook fetch:

const product = await fetch(`${cms}/products/${id}`, {
  next: { tags: [`product:${id}`] },
}).then((r) => r.json());

The amount on the page did not come from that fetch. It came from a helper we wrapped so the Stripe retrieve would not run on every RSC render:

export const getStripePrice = unstable_cache(
  async (priceId: string) => stripe.prices.retrieve(priceId),
  ['stripe-price'],
  { tags: ['stripe-price'] },
);

The layout around the page fetched the same product for the breadcrumb and the "live / draft" chip. That helper was marked 'use cache' and never called cacheTag. No next.tags. No unstable_cache tags. A cached function with no tag is not a tagged function that forgot a string. It is a different store.

revalidateTag('product') — and later the slightly less wrong namespaced tag product:${id} — punched the Data Cache entries that actually carried that tag. The Stripe helper kept the old Price object under stripe-price. The layout kept the old chip. The Full Route Cache still had an RSC payload whose Cache-Control said public, s-maxage=720. The edge looked at a 200 with eleven minutes of freshness left and did not ask the origin anything. That is the twelve minutes. Not a Vercel outage. Not "eventual consistency." A leftover route TTL plus two caches that were never in the tag we invalidated.

A hard refresh sends Cache-Control: no-cache. The browser skips the edge, the origin runs, you see the new price, you close the ticket. The next visitor who did not smash Cmd-Shift-R still gets yesterday. That is why "it works for me" is not a cache proof.

The Layout That Never Joined the Tag

The layout miss is the one that survives a correct page tag. Soft navigations reuse the layout segment. revalidateTag on a tag the page owns does not evict a layout fetch that never opted in. The product body can flip while the status chip, the price in the header, and the JSON-LD in layout.tsx keep last hour's row. We stared at the page fetch for an hour before someone diffed the layout helper.

'use cache' and unstable_cache make this worse because they look like the fetch cache and are not. Tags you pass to fetch(..., { next: { tags } }) do not leak into a cached function one import away. You have to pass the same tag into unstable_cache's options, or call cacheTag with product:${id} inside the 'use cache' function. If you do not, revalidateTag is a no-op for that read, and the function will keep returning the memoized value until its TTL expires — which, in our case, lined up uncomfortably with the CDN s-maxage.

revalidatePath is the other lever people reach for once the tag "does nothing." revalidatePath('/products/:id') marks that route's Full Route Cache stale. The same call with 'layout' includes the layout segment. It still will not evict an unstable_cache entry tagged stripe-price, and it will not override a CDN s-maxage the platform is allowed to honor on the next 200. Path and tag are not two spellings of the same purge. Tag is for the data owner. Path is for the route tree that rendered it. You need the owner to be one string, and you need the route headers to let the purge be visible.

The Checklist We Run After a Tagged Write

Four checks, in this order, before anyone is allowed to say "cache is busted."

One tag owner. Pick the business object — product:${id}, not product and price and cms as three synonyms. Every fetch, every unstable_cache, every 'use cache' function that must flip on that write takes that tag. The layout is not exempt. The webhook or CMS write calls revalidateTag once, after the durable write commits. Two tags for one object is how the second store survives.

revalidateTag versus revalidatePath. If many routes read the same owner, tag. If one route must die now and you are not sure the layout joined the tag, path with 'layout'. Calling both as a superstition hides the missing tag for the next incident. After the Stripe claim we now do:

await db.product.update({ where: { id }, data: { priceId, amount } });
revalidateTag(`product:${id}`);
revalidatePath(`/products/${id}`, 'layout');

The path call is there because the layout used to be the hole. It stays until the layout helper is on the same tag and we have a week of origin logs that prove it.

Cache-Control on the route. Read what the route actually emits — export const revalidate, next.config headers, vercel.json, a headers() override. If the edge is allowed to treat a 200 as fresh for twelve minutes, revalidateTag can be correct and the visitor still loses. Product and status pages that must flip on a money write do not get a decorative s-maxage=720. If you need a TTL for the anonymous marketing surface, give that surface its own route, not the one the webhook just mutated.

Prove it with a request-id, not a hard refresh. Stamp an id on the write and on the render. Log tag, path, and id when you invalidate. Log the same plus product.updatedAt only on an origin render — the line that does not run when the CDN serves the cached RSC. Then fetch the page as a stranger would: no pragma, no DevTools "disable cache," no Cmd-Shift-R.

console.info('revalidate', { tag, path, requestId });
// page.tsx, origin miss only
console.info('render', {
  tag: `product:${id}`,
  requestId: headers().get('x-request-id'),
  productUpdatedAt: product.updatedAt,
});

If the write log exists and the render log does not, the edge still thinks the 200 is fresh. That is the whole bug, and it is the same "green logs, wrong layer" shape as a serverless path that never ran: the origin looks healthy because it was never asked.

Three Rules That Survive the Next Cache API

Three rules survive this writeup and generalize past whatever Next.js calls the memo this year.

A tag is a membership list, not a broadcast. revalidateTag only evicts entries that opted in. Fetch tags, unstable_cache tags, and cacheTag() inside 'use cache' are three opt-in forms. A layout that never joined is not "eventually consistent." It is a second page.

A 200 with remaining freshness is a cache hit. The edge does not owe you a trip to origin because you invalidated a store it is not looking at. Read Cache-Control. Drop leftover revalidate / s-maxage on routes that a webhook is allowed to mutate. If you need ISR, make the TTL the incident budget you are willing to explain to support.

Hard refresh is not an invalidation test. It bypasses the CDN on purpose. The test is an uncached-looking client request whose request-id either appears in the origin render log or does not. No render line means the purge did not reach the layer the visitor hits.

The composition is the note. We treated revalidateTag as "the page is new now" because the function name sounds like a purge and the admin fetch was untagged. Production kept the old RSC at the edge until the TTL expired. One tag owner, an honest Cache-Control, and a request-id in the logs would have shown the hole in the first minute — the same minute we spent refreshing our own browser.

If you are wiring a Stripe or CMS write into an App Router page and the public URL still shows the previous row after revalidateTagbook a free AI Potenzial-Check. The client-side cousin is the missed invalidation after a mutation; the durable-write half of the webhook is the idempotency claim that should run before this cache call ever does.

NEXT STEPWas this useful?