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 real regeneration from a re-render without squinting at timestamps.
What to check
Timing, in one sequence. Press revalidateTag('stations', 'max'). The panels do not change — you were served stale, and regeneration started behind you. Reload once: now the stamps move. Then press revalidateTag('stations', { expire: 0 }) and watch them move on the spot instead. One reload is never enough on the stale path, and that catches everyone out at least once.
Blast radius — use the stale path for this. Press revalidateTag('stations:paris', 'max') in the narrow-tag group, then reload twice. On the second reload only the product panel moves; the other three keep their stamps. Then try revalidateTag('stations', 'max') and all four move together.
The immediate calls are not selective. Measured on this page: { expire: 0 } on any tag this route uses re-executes every cached scope on the route, not just the tagged one — even a tag as narrow as stations:paris, and even the inventory panel, which does not carry that tag. The stale path on the same tag moves exactly one panel. So the granularity of your tags buys you precision only on the stale path; the immediate path trades it for speed.
A tag this route does not use moves nothing here either way — 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 confidently serve 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, four different timings
updateTag('stations')— Expire immediately. The next read blocks for fresh data — stamps move on this render. Server Actions only.revalidateTag('stations', 'max')— Mark stale. Expect ONE stale read first — stamps move on the reload after this, not on this one.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 rather than tag-level: drops this route's cached render, leaving tagged data entries alone.
Narrower tags, stale path — this is where granularity shows
revalidateTag('stations:index', 'max')— Reload twice. On the second reload only the category index panel moves.revalidateTag('stations:london', 'max')— Reload twice. Only the London station panel moves.revalidateTag('stations:paris', 'max')— Reload twice. Only the single product panel moves — verified: the other four keep their stamps.
Same narrow tags, immediate path — compare the blast radius
updateTag('stations:paris')— Immediate, and not selective: expect every cached panel on this route to move, not just the product.revalidateTag('stations:paris', { expire: 0 })— Measured to re-execute 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
- 125ms
ran at exec o5dmfkage …
- London17.7°C · 71% · 1020.3hPa
- Paris19.1°C · 64% · 1018.5hPa
- Reykjavik10.1°C · 84% · 963.2hPa
- Tokyo20.2°C · 96% · 1012.5hPa
Tagged twice. Either 'stations' or 'stations:index' will expire it — tags are additive, not exclusive.
Station — London
use cache- cacheLife
- blog
- cacheTag
- stations, stations:london
- upstream
- 471ms
ran at exec 3f2xbfage …
station London
- temperature
- 17.7°C
- humidity
- 71%
- wind
- 8.3km/h
- pressure
- 1020.3hPa
- elevation
- 16m
- observed
- 2026-09-24T21:00Z
Invalidating the other station leaves this untouched, which is the argument for per-entity tags over one broad tag.
Station — Paris
use cache- cacheLife
- blog
- cacheTag
- stations, stations:paris
- upstream
- 468ms
ran at exec 11izzvage …
station Paris
- temperature
- 19.1°C
- humidity
- 64%
- wind
- 3.3km/h
- pressure
- 1018.5hPa
- elevation
- 36m
- observed
- 2026-09-24T21:00Z
The other narrow tag. Between them these two prove the blast radius is per entity, not per route.
Probe — tagged 'stations'
use cache- cacheLife
- max
- cacheTag
- stations
- upstream
- 229ms
ran at exec 3gcyfzage …
probe 708,446
cacheLife('max') and an upstream that never repeats. If the number changes, the scope truly re-executed; nothing else on this page can prove that as directly.
Read your own writes
cacheLife('max'), so its window will never rescue it — only the inventory tag can. The second button mutates without invalidating, to show what a forgotten updateTag actually 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 vuhhmiage …
- 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 is not available in a Route Handler — only in Server Actions. A webhook therefore has to use revalidateTag, and if it needs the data gone immediately it must pass { expire: 0 } rather than relying on 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.