revalidateTag hat den Edge nicht geleert: Warum unsere Next.js-Seite 12 Minuten alte Daten zeigte
Nach einem Stripe-Preis-Write haben wir revalidateTag aufgerufen und erwartet, dass die Produktseite sofort kippt. Vercels Data Cache und ein CDN-gecachter RSC-Payload haben den alten 200 rund zwölf Minuten weiter ausgeliefert. Das ist die Falle, Fetch-Cache-Tags, unstable_cache / 'use cache' und ein Layout zu mischen, das nie demselben Tag beigetreten ist — plus die Checkliste, die wir jetzt abarbeiten, bevor wir einem Hard Refresh trauen.
revalidateTag hat den Edge nicht geleert: Warum unsere Next.js-Seite 12 Minuten alte Daten zeigte
Der teuerste Cache-Bug ist der, der auf deinem Laptop behoben aussieht. Wir haben einen Stripe-Preis aus dem Webhook geschrieben, den neuen Betrag in Postgres persistiert, revalidateTag('product') aufgerufen und zugesehen, wie die Admin-UI kippte. Support schickte einen Screenshot der öffentlichen Produktseite, immer noch mit dem Preis von gestern. Ein Hard Refresh auf unserer Maschine zeigte den neuen. Ein Inkognito-Fenster in einer anderen Stadt, und der Status-Chip im Layout, nicht. Zwölf Minuten später hat der Edge den neuen Payload von allein ausgeliefert, als hätten wir nie etwas aufgerufen. Das ist die Aufzeichnung davon, dass revalidateTag den Edge nicht leert — Fetch-Cache-Tags, unstable_cache / 'use cache' und ein Layout, das nie demselben Tag beigetreten ist, zu mischen — plus ein 200, den das CDN als noch frisch behandelt hat.
Der Write selbst hatte dieselbe Form, die wir bereits als Geld-Pfad behandeln. Ein Stripe-Event landet, wir claimen event.id vor der Side-Effect, persistieren den Preis, dann sagen wir Next.js, die getaggten Reads seien tot. Die Docs lesen sich, als reiche dieser letzte Aufruf. Er reicht für einen Cache. Produktion auf Vercel sind drei.
Der Aufruf, der nur eine Schicht getroffen hat
App Router gibt dir drei Orte, an denen ein Produkt leben kann, und die teilen sich kein Gehirn.
Die Page hat den Lehrbuch-Fetch gemacht:
const product = await fetch(`${cms}/products/${id}`, {
next: { tags: [`product:${id}`] },
}).then((r) => r.json());
Der Betrag auf der Seite kam nicht aus diesem Fetch. Er kam aus einem Helper, den wir gewrappt haben, damit das Stripe-Retrieve nicht bei jedem RSC-Render läuft:
export const getStripePrice = unstable_cache(
async (priceId: string) => stripe.prices.retrieve(priceId),
['stripe-price'],
{ tags: ['stripe-price'] },
);
Das Layout um die Page hat dasselbe Produkt für Breadcrumb und den "live / draft"-Chip geholt. Dieser Helper war 'use cache' und hat nie cacheTag aufgerufen. Keine next.tags. Keine unstable_cache-Tags. Eine gecachte Funktion ohne Tag ist keine getaggte Funktion, der ein String entfallen ist. Sie ist ein anderer Store.
revalidateTag('product') — und später das etwas weniger falsche namespaced Tag product:${id} — hat die Data-Cache-Einträge gelocht, die diesen Tag wirklich trugen. Der Stripe-Helper behielt das alte Price-Objekt unter stripe-price. Das Layout behielt den alten Chip. Der Full Route Cache hatte immer noch einen RSC-Payload, dessen Cache-Control public, s-maxage=720 sagte. Der Edge hat einen 200 mit elf Minuten Restfrische angesehen und den Origin nach nichts gefragt. Das sind die zwölf Minuten. Kein Vercel-Ausfall. Keine "eventual consistency." Ein übrig gebliebenes Route-TTL plus zwei Caches, die nie in dem Tag waren, den wir invalidiert haben.
Ein Hard Refresh schickt Cache-Control: no-cache. Der Browser überspringt den Edge, der Origin läuft, du siehst den neuen Preis, du schliesst das Ticket. Der nächste Besucher, der nicht Cmd-Shift-R gedrückt hat, bekommt weiter gestern. Deshalb ist "bei mir geht's" kein Cache-Beweis.
Das Layout, das dem Tag nie beigetreten ist
Der Layout-Treffer ist der, der einen korrekten Page-Tag überlebt. Soft Navigations wiederverwenden das Layout-Segment. revalidateTag auf einem Tag, den die Page besitzt, wirft keinen Layout-Fetch raus, der nie opt-in war. Der Produkt-Body kann kippen, während Status-Chip, Preis im Header und das JSON-LD in layout.tsx die Zeile der letzten Stunde behalten. Wir haben eine Stunde auf den Page-Fetch gestarrt, bevor jemand den Layout-Helper gedifft hat.
'use cache' und unstable_cache machen das schlimmer, weil sie wie der Fetch-Cache aussehen und es nicht sind. Tags, die du an fetch(..., { next: { tags } }) übergibst, sickern nicht in eine gecachte Funktion einen Import weiter. Du musst denselben Tag in die unstable_cache-Optionen legen oder cacheTag mit product:${id} innerhalb der 'use cache'-Funktion aufrufen. Tust du das nicht, ist revalidateTag für diesen Read ein No-op, und die Funktion gibt den memoisierten Wert weiter zurück, bis ihr TTL abläuft — was sich in unserem Fall unangenehm mit dem CDN-s-maxage gedeckt hat.
revalidatePath ist der andere Hebel, nach dem Leute greifen, sobald der Tag "nichts tut." revalidatePath('/products/:id') markiert den Full Route Cache dieser Route als stale. Derselbe Aufruf mit 'layout' nimmt das Layout-Segment mit. Es wirft trotzdem keinen unstable_cache-Eintrag mit Tag stripe-price raus, und es überstimmt kein CDN-s-maxage, das die Plattform beim nächsten 200 ehren darf. Path und Tag sind nicht zwei Schreibweisen derselben Purge. Tag ist für den Daten-Owner. Path ist für den Route-Tree, der ihn gerendert hat. Der Owner muss ein String sein, und die Route-Header müssen die Purge sichtbar werden lassen.
Die Checkliste nach einem getaggten Write
Vier Checks, in dieser Reihenfolge, bevor irgendwer "Cache ist geleert" sagen darf.
Ein Tag-Owner. Nimm das Geschäftsobjekt — product:${id}, nicht product und price und cms als drei Synonyme. Jeder fetch, jedes unstable_cache, jede 'use cache'-Funktion, die bei diesem Write kippen muss, nimmt diesen Tag. Das Layout ist nicht ausgenommen. Der Webhook- oder CMS-Write ruft revalidateTag einmal auf, nachdem der durable Write committed hat. Zwei Tags für ein Objekt sind, wie der zweite Store überlebt.
revalidateTag gegen revalidatePath. Wenn viele Routes denselben Owner lesen, Tag. Wenn eine Route jetzt sterben muss und du nicht sicher bist, dass das Layout dem Tag beigetreten ist, Path mit 'layout'. Beides als Aberglaube aufzurufen versteckt den fehlenden Tag für den nächsten Vorfall. Nach dem Stripe-Claim machen wir jetzt:
await db.product.update({ where: { id }, data: { priceId, amount } });
revalidateTag(`product:${id}`);
revalidatePath(`/products/${id}`, 'layout');
Der Path-Aufruf ist da, weil das Layout das Loch war. Er bleibt, bis der Layout-Helper auf demselben Tag sitzt und wir eine Woche Origin-Logs haben, die das belegen.
Cache-Control auf der Route. Lies, was die Route wirklich emitiert — export const revalidate, next.config-Headers, vercel.json, ein headers()-Override. Wenn der Edge einen 200 zwölf Minuten als frisch behandeln darf, kann revalidateTag korrekt sein und der Besucher verliert trotzdem. Produkt- und Statusseiten, die bei einem Geld-Write kippen müssen, bekommen kein dekoratives s-maxage=720. Wenn du ein TTL für die anonyme Marketing-Fläche brauchst, gib dieser Fläche eine eigene Route, nicht die, die der Webhook gerade mutiert hat.
Beweise es mit einer Request-Id, nicht mit einem Hard Refresh. Stempel eine Id auf den Write und auf den Render. Logge Tag, Path und Id, wenn du invalidierst. Logge dasselbe plus product.updatedAt nur bei einem Origin-Render — die Zeile, die nicht läuft, wenn das CDN den gecachten RSC ausliefert. Dann hol die Seite so, wie ein Fremder sie holt: kein pragma, kein DevTools-"disable cache", kein Cmd-Shift-R.
console.info('revalidate', { tag, path, requestId });
// page.tsx, nur Origin-Miss
console.info('render', {
tag: `product:${id}`,
requestId: headers().get('x-request-id'),
productUpdatedAt: product.updatedAt,
});
Wenn das Write-Log existiert und das Render-Log nicht, hält der Edge den 200 immer noch für frisch. Das ist der ganze Bug, und es ist dieselbe "grüne Logs, falsche Schicht"-Form wie ein serverloser Pfad, der nie gelaufen ist: der Origin sieht gesund aus, weil er nie gefragt wurde.
Drei Regeln, die die nächste Cache-API überleben
Drei Regeln überleben diese Aufzeichnung und verallgemeinern sich über das hinaus, wie Next.js das Memo in diesem Jahr nennt.
Ein Tag ist eine Mitgliedsliste, kein Broadcast. revalidateTag wirft nur Einträge raus, die opt-in waren. Fetch-Tags, unstable_cache-Tags und cacheTag() in 'use cache' sind drei Opt-in-Formen. Ein Layout, das nie beigetreten ist, ist nicht "eventually consistent." Es ist eine zweite Seite.
Ein 200 mit Restfrische ist ein Cache-Hit. Der Edge schuldet dir keine Fahrt zum Origin, weil du einen Store invalidiert hast, den er nicht anschaut. Lies Cache-Control. Wirf übrig gebliebenes revalidate / s-maxage von Routes, die ein Webhook mutieren darf. Wenn du ISR brauchst, mach das TTL zum Incident-Budget, das du Support erklären willst.
Hard Refresh ist kein Invalidierungs-Test. Er umgeht das CDN absichtlich. Der Test ist eine uncached-aussehende Client-Anfrage, deren Request-Id im Origin-Render-Log entweder auftaucht oder nicht. Keine Render-Zeile heisst, die Purge hat die Schicht nicht erreicht, die der Besucher trifft.
Die Komposition ist die Notiz. Wir haben revalidateTag als "die Seite ist jetzt neu" behandelt, weil der Funktionsname nach Purge klingt und der Admin-Fetch untagged war. Produktion hat den alten RSC am Edge behalten, bis das TTL abgelaufen ist. Ein Tag-Owner, ein ehrliches Cache-Control und eine Request-Id in den Logs hätten das Loch in der ersten Minute gezeigt — derselben Minute, die wir mit dem Neuladen unseres eigenen Browsers verbracht haben.
Wenn du einen Stripe- oder CMS-Write in eine App-Router-Seite verdrahtest und die öffentliche URL nach revalidateTag immer noch die vorherige Zeile zeigt — buche einen kostenlosen AI-Potenzial-Check. Der clientseitige Cousin ist die verpasste Invalidierung nach einer Mutation; die Durable-Write-Hälfte des Webhooks ist der Idempotenz-Claim, der laufen sollte, bevor dieser Cache-Aufruf das überhaupt darf.