Cache tags and invalidation
Five tagged cache entries at three levels of granularity, and every way to invalidate them. The action log records the wall-clock time of each call so you can line it up against the ran at stamps.
The probe panel reads an upstream that returns a different integer on every call, so you can tell a regeneration from a re-render without comparing timestamps.
What to check
Timing, in one sequence. Press revalidateTag('stations', 'max'). The panels do not change. You were served stale, and regeneration started in the background. Reload twice, and on the second reload the stamps move. Then press revalidateTag('stations', { expire: 0 }) and watch them move on the spot instead.
Blast radius on the stale path. Press revalidateTag('stations:paris', 'max') in the narrow-tag group, then reload twice. On the second reload only the Paris panel moves, and the other four keep their stamps. Then try revalidateTag('stations', 'max') and the four station-tagged panels move together.
On the Pantheon handler the immediate calls are not selective. { expire: 0 } on any tag this route uses re-executes every cached scope on the route, even for a tag as narrow as stations:paris. That includes the inventory panel, which does not carry the tag. Stock Next.js handlers move only the tagged panel. The stale path moves exactly one panel on both.
A tag this route does not use moves nothing here on either path. Try market. Tag scope still decides which routes are touched.
Read your own writes. Adjust stock. The cached inventory panel already shows the new number, because the action mutated and then called updateTag. Then use the “no invalidation” button and watch the panel keep serving a number that is now wrong.
The control. refresh() should move nothing at all. If it does, something else expired at the same moment.
Invalidate
Same tag, different timings
updateTag('stations'): Expire immediately. The next read blocks for fresh data, so stamps move on this render. Server Actions only.revalidateTag('stations', 'max'): Mark stale. Expect stale reads first. Stamps move on a later reload, not on this render.revalidateTag('stations', { expire: 0 }): Expire with no stale window. Next read is a hard miss. This is the shape to use from a webhook.refresh(): The control case. Re-renders the route without invalidating anything, so nothing should move.revalidatePath('/tags'): Path-level instead of tag-level. Every cached scope read on this route re-runs, whatever its tags or cacheLife.
Narrower tags on the stale path, where granularity shows
revalidateTag('stations:index', 'max'): Reload twice. On the second reload only the station roll-up panel moves.revalidateTag('stations:london', 'max'): Reload twice. Only the London station panel moves.revalidateTag('stations:paris', 'max'): Reload twice. Only the Paris station panel moves, and the other four keep their stamps.
Same narrow tags on the immediate path, to compare the blast radius
updateTag('stations:paris'): Immediate. On the Pantheon handler every cached panel on this route moves, not just Paris. Stock handlers move only Paris.revalidateTag('stations:paris', { expire: 0 }): On the Pantheon handler this re-executes every cached scope on this route, including the inventory panel, which does not carry this tag.updateTag('stations:index'): Same again with a different narrow tag, to confirm it is the call and not the tag.
Tagged station entries
Station roll-up
use cache- cacheLife
- blog
- cacheTag
- stations, stations:index
- upstream
- 490ms
ran at exec py38vvage …
- London16.3°C · 63% · 1026.4hPa
- Paris16.8°C · 65% · 1025.7hPa
- Reykjavik9.5°C · 89% · 985.9hPa
- Tokyo20.6°C · 71% · 1008.3hPa
Tagged twice. Either 'stations' or 'stations:index' expires it.
Station — London
use cache- cacheLife
- blog
- cacheTag
- stations, stations:london
- upstream
- 483ms
ran at exec n56eciage …
station London
- temperature
- 16.3°C
- humidity
- 63%
- wind
- 10.1km/h
- pressure
- 1026.4hPa
- elevation
- 16m
- observed
- 2026-10-01T21:30Z
Invalidating the other station on the stale path leaves this untouched. That is the case for per-entity tags over one broad tag.
Station — Paris
use cache- cacheLife
- blog
- cacheTag
- stations, stations:paris
- upstream
- 484ms
ran at exec dwc8f0age …
station Paris
- temperature
- 16.8°C
- humidity
- 65%
- wind
- 3.4km/h
- pressure
- 1025.7hPa
- elevation
- 36m
- observed
- 2026-10-01T21:30Z
The other narrow tag. Together the two show that the stale-path blast radius is per entity, not per route.
Probe — tagged 'stations'
use cache- cacheLife
- max
- cacheTag
- stations
- upstream
- 233ms
ran at exec i9hkzlage …
probe 139,154
cacheLife('max') and an upstream that never repeats. If the number changes, the scope re-executed.
Read your own writes
cacheLife('max'), so only an invalidation refreshes it. The second button mutates without invalidating, to show what a forgotten updateTag looks like in the UI.Mutate the store
adjustStock(1, -5) + updateTag: Mutate, then expire the tag. The cached panel below already reflects it on this render.adjustStock(1, -5) with NO updateTag: The source changed but the panel will not. Compare against /api/inventory, which reads the store directly.resetStock(): Back to seed values, and invalidated.
Inventory — cached view of the store
use cache- cacheLife
- max
- cacheTag
- inventory
ran at exec g19x11age …
- 99 Essence Mascara Lash Princess
- 34 Eyeshadow Palette with Mirror
- 89 Powder Canister
Verify against the uncached source with: curl -s localhost:3000/api/inventory. If they disagree, a mutation skipped its invalidation.
Invalidating from outside the app
Webhook shape
updateTag works only in Server Actions, not in a Route Handler. A webhook therefore uses revalidateTag, and if it needs the data gone immediately it passes { expire: 0 } instead of the deprecated single-argument form.
# stale-while-revalidate (recommended for content updates) curl -s 'localhost:3000/api/revalidate?tag=stations&mode=stale' # expire immediately, next read is a blocking miss curl -s 'localhost:3000/api/revalidate?tag=stations&mode=now' # one station only curl -s 'localhost:3000/api/revalidate?tag=stations:paris&mode=now' # path instead of tag curl -s 'localhost:3000/api/revalidate?path=/tags'
Reload this page after each one and compare which stamps moved against the button with the same semantics.