cloudflare / cloudflare/vinext
Persist `'use cache: private'` entries in dev (request-scoped handler, scoped by cookies/headers)
- 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
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