Cache scopes

One probe — a random.org integer, different on every upstream call — through four different caches. Any two panels showing the same number share a cache entry; any two showing different numbers do not.

The private panel is the only one permitted to read cookies() and headers() inside its own cached scope. The other three throw next-request-in-use-cache if they try, and the restriction follows the call stack — a helper that reads a cookie fails the same way.

What to check

Shared against private. Open this page in two different browsers, or one normal and one private window. The shared and remote panels show the same number in both; the private panel differs per client.

Private is not persisted. Reload the private window. The private panel changes even inside its stale window, because nothing was written to a server cache — the client cache does not survive a page load.

Two handlers, verified. CACHE_DEBUG=true pnpm start and watch the handler logs while reloading. The shared and remote panels should log lookups; the private panel should log nothing.

The legacy path. Invalidate scope:legacy-fetch below. That tag only exists on a fetch(…, { next: { tags } }) call, so if that panel moves, the singular cacheHandler is alive and honouring tags.

Shared server caches

Both are visible to every visitor and every server instance. Prerenderable, so no Suspense boundary needed.

'use cache'

use cache
cacheLife
blog
cacheTag
scope:shared
upstream
298ms

ran at exec 411alaage …

probe 390,805

Stored via cacheHandlers.default. Shared across every visitor and every server instance.

'use cache: remote'

use cache: remote
cacheLife
blog
cacheTag
scope:remote
upstream
232ms

ran at exec 50zfl5age …

probe 311,817

Stored via cacheHandlers.remote. Durable and shared across all server instances.

Per-client and legacy

Both of these are excluded from the static shell — the private scope by definition, and the legacy fetch because its surrounding scope awaits connection(). They need <Suspense>.

'use cache: private'

suspense fallback

streaming at request time — this is what ships in the static shell

fetch force-cache (legacy)

suspense fallback

streaming at request time — this is what ships in the static shell

Invalidate per scope

Each scope has its own tag

  • revalidateTag('scope:shared', { expire: 0 }) — Only the 'use cache' panel should move.
  • revalidateTag('scope:remote', { expire: 0 }) — Only the remote panel should move — proof the two handler slots hold separate entries.
  • revalidateTag('scope:legacy-fetch', { expire: 0 }) — Tagged via fetch's next.tags rather than cacheTag. If this works, the legacy cacheHandler is honouring tags.
There is no tag for the private scope, and no button for it. Private cache entries are never written to a server cache, so there is nothing for a tag to invalidate — the stale window is the only control you have over them.

The three directives, side by side

use cacheuse cache: remoteuse cache: private
Server-side storagecacheHandlers.defaultcacheHandlers.remotenone
Cache scopeall usersall usersone client
May read cookies()/headers()no — pass as argumentsno — pass as argumentsyes
In the static shellyes, if long-lived enoughyes, if long-lived enoughnever
Invalidated by cacheTagyesyesno
Survives a page reloadyesyesno
Survives a deploynonon/a

Reach for remote when a scope resolves at request time rather than in the shell — each serverless instance has its own memory, so a shared store is what lifts the hit rate. For shell content, plain use cache is usually enough.