Cache scopes

One probe, a random.org integer that differs on every upstream call, goes through five different caches. Two panels that show the same number share a cache entry.

The private panel is the only one permitted to read cookies() and headers() inside its own cached scope. The other four throw next-request-in-use-cache if they try, and the restriction follows the call stack, so 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, because it re-executes on every request.

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 and the client cache does not survive a page load.

Check both handlers. Run 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. Do the same with scope:unstable-cache for the unstable_cache panel.

Shared server caches

Every visitor and every server instance sees both. They can be prerendered, so they need no Suspense boundary.

'use cache'

use cache
cacheLife
blog
cacheTag
scope:shared
upstream
246ms

ran at exec 37eov8age …

probe 117,154

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

'use cache: remote'

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

ran at exec 6fz9wgage …

probe 137,540

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

Per-client and legacy

None is in the static shell. The private scope never is, and both legacy scopes await connection() first. All three 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.

unstable_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.
  • revalidateTag('scope:legacy-fetch', { expire: 0 }): Tagged with fetch's next.tags instead of cacheTag. If the panel moves, the legacy cacheHandler honours tags.
  • revalidateTag('scope:unstable-cache', { expire: 0 }): Tagged through unstable_cache's options. Only the unstable_cache panel should move.
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 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

Use remote when a scope resolves at request time instead of in the shell. Each serverless instance has its own memory, so a shared store raises the hit rate. For shell content, plain use cache is usually enough.