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

The panel below caches an in-app mutable store with 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.