cloudflare / cloudflare/vinext

Persist `'use cache: private'` entries in dev (request-scoped handler, scoped by cookies/headers)

Open
#1,937 0 comments 0 reactions 0 assignees View on GitHub
nextjs-tracking
Dominant language
TypeScript
Stars
8.8k
Forks
406
Avg merge
2d 11h
Merged PRs (30d)
122

Description

## Next.js Change

**Commit:** [`5b0aa04`](https://github.com/vercel/next.js/commit/5b0aa04b1042abb492504a378cfc08416a937273)
**PR:** [#94694](https://github.com/vercel/next.js/pull/94694)

## What changed

Private `'use cache'` entries (`'use cache: private'`) are now persisted in `next dev` in a dedicated in-memory cache handler. Previously they were never stored, so every reload re-ran them from scratch and showed as a cache miss. Production behavior is unchanged: private caches remain non-persisted.

### Mechanics

1. **A dedicated dev-only handler** is registered alongside the kind-keyed handlers map, but kept **out** of it. This is critical: private entries can hold data derived from the incoming request (cookies, headers), so a user-configured `default` handler must never be substituted in for the private kind — it could leak request-specific data to a remote/persistent backend. The handler is gated on `process.env.__NEXT_DEV_SERVER` so it only exists in dev.
2. **Coarse cache key** is scoped by the request's cookies and headers so entries for distinct requests don't collide. The scoping excludes:
- **Next-internal cookies** that aren't application data: the HMR refresh hash (already part of the cache key) and the instant-navigation cookie (toggles while a navigation lock is held).
- **Transport / content-negotiation headers** that vary between otherwise-equivalent requests: `cache-control` (added by browser reloads), `accept` and `sec-fetch-*` (differ between HTML navigation and RSC requests).
3. **Cache life is forced** to `revalidate: 0` with a 5-minute `expire`. With the dev-stale change ([#94662](https://github.com/vercel/next.js/pull/94662), tracked at #1936), each read serves the stale entry immediately and warms a fresh one in the background via the existing stale-while-revalidate path.
4. **Cross-request deduplication now applies to private caches in dev too.** Concurrent requests with identical scoped key share a single fill. Production still skips this — private data must not be shared across requests when persisted backends could be involved.
5. **Race fix in `saveToCacheHandler`.** The metadata a cross-request joiner awaits now resolves only **after** the entry has been written to the handler. Previously the joiner could resolve its metadata before the handler write landed and miss the entry on re-read. This race was present for public caches too but had never surfaced because the public test paths didn't exercise it.
6. **The defensive invariant that rejected reading a private entry from a handler is removed.** Dev now legitimately reads persisted private entries, and production never registers a private handler at all, so the guard isn't needed anywhere.
7. **Root params tracking** — `root-params.ts` now also tracks the read for private caches (existing public-cache path generalized).

### Files changed (non-test)

- `packages/next/src/server/app-render/work-unit-async-storage.external.ts` (+7/-3)
- `packages/next/src/server/request/root-params.ts` (+6/-1)
- `packages/next/src/server/use-cache/handlers.ts` (+30/-0) — the new dev private handler registration
- `packages/next/src/server/use-cache/use-cache-wrapper.ts` (+224/-66) — request-scoped key construction, dev/prod branching, save-write ordering fix

## Why

In dev, private caches were a no-op for caching purposes — every reload ran them from scratch. That made the dev inner loop slow for any data derived from auth/session/cookies, and made the cache indicator perma-cold for those routes. Persisting them in dev makes warm reloads fast while keeping production semantics intact.

The "kept out of the kind-keyed handlers map" detail is the load-bearing piece. A user-configured `default` handler typically writes to a persistent or shared backend (Redis, KV, etc.). If private caches were resolved through the kind-keyed map, a user override would silently capture them too and leak request-specific data across requests. Keeping the dev private handler as a separate, dev-only, never-overridable register makes that class of leak impossible.

## Impact on vinext

vinext supports `'use cache'` via the data cache handler interface (the default is `MemoryCacheHandler` in all runtimes, including Workers). Private caches are part of the same surface area. When/if vinext implements `'use cache: private'` dev persistence:

1. **Separate, non-overridable handler for private kind in dev.** Do not allow a user-configured `default` data cache handler to capture private entries. The dev private handler must be wired in at a level the user config cannot reach.
2. **Production must not persist private entries.** This is the security-critical invariant. In Workers, this is especially important — KV is shared across requests by design.
3. **Coarse key derived from cookies + headers**, with the exclusion list above:
- Drop the equivalent of HMR-refresh / instant-navigation internal cookies (vinext has its own dev plumbing here; identify the equivalents).
- Drop `cache-control`, `accept`, `sec-fetch-*` (and any other transport/content-negotiation headers) before keying.
4. **Force `revalidate: 0`, `expire: 5min`** so the dev private handler always serves stale + warms in background. This depends on #1936 (serving stale until `expire` in dev) landing first.
5. **Cross-request dedup in dev only.** vinext's dedup map (in `isr-cache.ts` or equivalent) must allow private entries to share fills in dev but skip dedup for private in production.
6. **Save-then-resolve ordering.** When writing a cache entry, resolve any cross-request joiner's metadata **after** the handler write completes. This affects public caches too, even though the bug only manifests for private — fix it in the shared code path.
7. **Follow-up:** Next.js notes that keying by only the cookies/headers a cache actually reads is left as a future improvement (read root params are already tracked that way). Same applies to vinext when implementing this.

The full PR description is worth reading carefully — the cookie/header exclusion list and the save-write ordering are both subtle and easy to get wrong.

## Related

- #1936 — Dev cache handler: serve stale `'use cache'` entries until `expire` (depends on this)
- #1919 — Cache Components dev: end cache-signal read for deferred short-lived `'use cache'` entries
- Cache handler interface: `packages/vinext/src/server/cache-handler.ts` / `MemoryCacheHandler`

Contributor guide

Open the contributing guide

Research direction

Start with packages/vinext/src/server/cache-handler.ts and MemoryCacheHandler, then trace the dev cache and dedup flow in isr-cache.ts or its equivalent. Use #1936 and the linked Next.js PR as behavioral references. Done means private entries persist only in dev with request-scoped cookie/header keys, remain isolated from user handlers in production, deduplicate correctly, and resolve writes before joiners continue.

Written by the indexing model from the issue text.

Assessment

Tech stack
next.js, typescript
Domain
backend, performance
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.