cloudflare / cloudflare/vinext
Dev cache handler: serve stale `'use cache'` entries until `expire` (not `revalidate`) in dev
- Dominant language
- TypeScript
- Stars
- 8.8k
- Forks
- 406
- Avg merge
- 2d 6h
- Merged PRs (30d)
- 120
Description
## Next.js Change
**Commit:** [`a9e076d`](https://github.com/vercel/next.js/commit/a9e076d5af0c415d21ba725ff81ddbbdb6c0f524)
**PR:** [#94662](https://github.com/vercel/next.js/pull/94662)
## What changed
The default in-memory cache handler now behaves differently in dev vs production for stale entries:
- **Production (`next start`, `next build --debug-prerender`)** — unchanged. An entry is dropped from the cache once `timestamp + revalidate * 1000` passes (treated as missing). In-memory caches are fragile, so warming a replacement in the background is not worth it when it's likely to be evicted before use.
- **Dev (`next dev`)** — an entry is now retained and served until `timestamp + expire * 1000` passes. The wrapper's existing stale-while-revalidate path warms a fresh entry in the background. The branch is gated on `process.env.__NEXT_DEV_SERVER`.
The implementation is a one-line conditional in `packages/next/src/server/lib/cache-handlers/default.ts` `get`:
```ts
const maxAgeSeconds = process.env.__NEXT_DEV_SERVER
? entry.expire
: entry.revalidate
if (performance.timeOrigin + performance.now() > entry.timestamp + maxAgeSeconds * 1000) {
// drop as expired
return undefined
}
```
Tag-based discard (`revalidateTag`) is preserved, so explicit invalidation still works in dev.
## Why
Three motivations stated in the PR:
1. **Dev inner loop speed.** A warm reload of a short-lived cache used to be a cold miss that re-ran the cache function on every reload (because the entry expired at `revalidate`). It's now an immediate stale-hit served from the handler with a background refresh.
2. **Foundation for upcoming dev-only caching changes.** Two follow-ups depend on this:
- Persisting `'use cache: private'` entries in dev (the next commit in this batch, #94694).
- Keeping `cacheMaxMemorySize: 0` fast in dev.
Both serve a previous value while warming a fresh one in the background, which only works once the handler stops dropping stale entries in dev.
3. **Removes test workarounds.** Several Cache Components dev fixtures had to use long `revalidate` values to avoid the handler dropping the entry between renders. Those workarounds are removed in the same PR; the fixtures now use natural `cacheLife('seconds')` and short `revalidate` values, and their warm reloads are stale-hits served by the dev server.
## Impact on vinext
vinext's `MemoryCacheHandler` (the default data cache handler in all runtimes, including Cloudflare Workers when nothing is configured) is the moral equivalent of Next.js's default in-memory handler. The ISR layer wrapping it (`isr-cache.ts`) already implements stale-while-revalidate, so it has the same shape: handler returns or drops based on `revalidate`, wrapper does background refresh on stale.
Action items:
1. **Dev vs prod split in `MemoryCacheHandler` (or the equivalent default).** When running under `vinext dev` (or however vinext signals dev mode — the equivalent of `process.env.__NEXT_DEV_SERVER`), the handler should treat `expire` as the drop threshold instead of `revalidate`. The ISR wrapper's stale-while-revalidate path will warm a fresh entry while the stale one is served.
2. **Tag invalidation still hard-deletes.** Whatever signal vinext uses, `revalidateTag` must continue to discard tagged entries immediately. Only time-based expiry shifts from `revalidate` to `expire`.
3. **Production behavior unchanged.** In `vinext build` + Workers / `vinext start`, the handler must keep dropping at `revalidate` so the production semantics match Next.js exactly (revalidate is the contract for how fresh the served value is).
4. **Audit any vinext test fixtures that worked around the old behavior.** If any fixture uses an unrealistically long `revalidate` to keep an entry alive across a reload, it can probably be simplified once this change lands.
The Next.js behavior is **default-handler-specific**, not a contract every handler must implement. vinext can apply the same dev-vs-prod split inside its default in-memory handler without changing the public cache-handler interface. KV-backed or external handlers can keep their own policies.
## Related
- #1919 — Cache Components dev: end cache-signal read for deferred short-lived `'use cache'` entries
- ISR layer: `packages/vinext/src/server/isr-cache.ts`
- Default data cache handler: `MemoryCacheHandler` in vinext
Contributor guide
Assessment
This issue has not been assessed yet.