Skip to content

fix(cache): serve stale use cache entries during refresh#2682

Open
NathanDrake2406 wants to merge 11 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/use-cache-swr-v2
Open

fix(cache): serve stale use cache entries during refresh#2682
NathanDrake2406 wants to merge 11 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/use-cache-swr-v2

Conversation

@NathanDrake2406

@NathanDrake2406 NathanDrake2406 commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Fixes #2672

Summary

goal core change expected impact
Match use cache stale-while-revalidate semantics Serve valid stale entries during ordinary App Router requests and coordinate one background refresh per logical cache key Dynamic requests no longer wait for regeneration after the revalidate window
Reject unusable cache values Choose an explicit read action from the handler state and request policy Expired or unrecognized entries regenerate in the foreground and are never returned
Keep ISR artifacts fresh Use the request-scoped function-cache revalidation policy for both unstable_cache and use cache Page and route-handler ISR regeneration await fresh data instead of storing an artifact with a stale dependency
Isolate draft previews Bypass persistent shared-cache reads and writes while retaining the normal "use cache" execution scope Preview requests return fresh preview data and cannot replace published cache entries
Preserve cache-scope APIs during refresh Give detached refreshes a disabled draft-mode provider with empty headers and cookies Cached callbacks can read draftMode().isEnabled without inheriting request data
Coordinate background work Share one per-key scheduler across both cache APIs Deduplication, cleanup, error handling, and waitUntil attachment have one owner

Why

A cache value is usable only when both its stored state and its consumer allow it. An ordinary dynamic request may return a stale value immediately and refresh it in the background. A page or route-handler ISR regeneration must await that refresh so the new full-route artifact contains fresh data. An expired value is no longer usable in either context and must regenerate before the caller continues.

The request boundary owns this distinction because a cached function cannot know whether it is answering a user request or constructing a persistent artifact. Both function/data-cache APIs now consume the same decision:

handler state background request foreground regeneration
fresh (cacheState absent) serve serve
stale serve and revalidate revalidate
expired or unrecognized revalidate revalidate

Draft mode is a separate cache boundary. Preview data can differ from published data, so a draft request executes the callback without reading or populating the persistent shared cache.

Non-draft background refreshes still need the request APIs supported inside public cache scopes. draftMode().isEnabled must return false; it must not fail because the detached context omitted its provider. The refresh receives a disabled provider with empty header and cookie stores. The draft-mode secret is retained, but no request data is copied.

What changed

scenario before after
Ordinary App Router request Stale use cache data blocked on regeneration Returns the deserialized stale value and schedules one isolated refresh
Build-time or runtime ISR regeneration Runtime ISR could detach the refresh and store stale data in the regenerated artifact Both paths await the refresh under functionCacheRevalidationMode: "foreground"
Expired handler value Any state other than "stale" was accepted as a hit, so the memory handler could return an expired value indefinitely Both cache APIs skip the stored value, execute in the foreground, and write the fresh result
Draft-mode request Could return stale published data and write preview output into shared storage Executes inside the cache scope with no persistent get, set, or background refresh
Background callback using draftMode() The detached context had no provider, so regeneration threw Reads isEnabled: false from a minimal provider and completes the cache write
Background cache lifecycle unstable_cache and use cache owned separate pending maps and cleanup logic Both use one isolate-wide per-key scheduler
unstable_cache background refresh Executed inside the triggering request's context, so callback tags and observations mutated the request and nested stale reads kept the background policy Runs through the same isolated refresh context as use cache, with foreground nested reads
Refresh request state The initial implementation copied full headers and cookies into regeneration A named helper retains supported cache inputs, provides empty headers and cookies, and creates fresh response-output and fetch-deduplication containers

Stored tags and cache-control metadata are applied to a stale response only after successful deserialization. Refresh-time metadata stays in the isolated context.

Validation

  • Added explicit state-decision and expired-entry regressions for unstable_cache and use cache. Both behavior tests failed against the previous implementation by returning the stored expired value.
  • Added a nested-regeneration regression: a detached unstable_cache refresh that reads another stale entry regenerates it in the foreground, stores the fully fresh outer result, and leaves the triggering request's tags and observations untouched. It fails when the refresh runs inside the triggering request's context.
  • Existing regression coverage verifies non-blocking stale reads, cross-request deduplication, waitUntil ownership, metadata isolation, disabled draft-mode access without request-data inheritance, draft-mode bypass, write-failure retry, missing waitUntil, build-time foreground refresh, and runtime ISR foreground refresh.
  • Corrected the App Router page-builder test mock so the CI shard supplies the new isDraftModeEnabled() shim export.
  • Passed tests/shims.test.ts (1,266 tests) locally and all 51 CI checks (unit shards, integration, and e2e suites) at the current head.
  • Passed the repository-wide format, lint, and type check across 2,649 formatted files and 1,143 checked files.
  • The pre-commit hook passed its full check, staged unit and integration tests, and Knip.
Commands
vp test run tests/shims.test.ts
vp test run tests/app-page-element-builder.test.ts tests/unified-request-context.test.ts tests/app-page-dispatch.test.ts tests/app-route-handler-execution.test.ts tests/app-rsc-handler.test.ts
vp check
vp run vinext#build
git diff --check

Risk

Moderate. The behavior change is confined to persistent function/data-cache entries. Draft requests now match unstable_cache by bypassing persistent storage; request-local "use cache: private" remains available. Fresh published hits, misses, invalidated entries, the development bypass, and cache serialization retain their existing behavior. Unknown handler states fail closed by regenerating, so a custom handler that labels fresh values instead of leaving cacheState absent will lose a cache hit rather than serve a potentially unusable value.

The request policy is scoped to the function caches and named functionCacheRevalidationMode to make that boundary explicit; patched fetch() retains its response-stream, refetch-map, and timeout lifecycle. Converging both onto one request-level freshness policy is tracked in #2685.

A refresh that never settles remains deduplicated for the isolate lifetime. This preserves the existing unstable_cache lifecycle behavior and does not introduce a new timeout policy.

References

reference why it matters
Vinext issue #2672 Report and expected stale-while-revalidate behavior
Vinext issue #2685 Tracked follow-up: converge the fetch and function-cache freshness policies
Next.js issue #74882 Upstream bug report
Next.js PR #92636 Upstream use cache stale-while-revalidate fix
Next.js stale refresh implementation Returns stale data during dynamic rendering, skips outer metadata propagation, and tracks background work
Next.js expired-entry handling Treats an entry past expire as a miss and regenerates before use
Next.js empty draft-mode behavior Returns a disabled draft-mode object inside cache and prerender scopes when no enabled provider exists
Next.js draft-mode write guard Avoids saving generated "use cache" entries while draft mode is enabled
Next.js use cache SWR tests Non-blocking response and cross-request deduplication behavior ported to Vinext's harness
Next.js foreground ISR regression Establishes that ISR must await fresh cache data before completing the regenerated page
Next.js cacheLife documentation Documents immediate stale serving and background regeneration
Vinext cache-read policy Defines the function/data-cache policy boundary and explicit read actions
Vinext cache-read decision Maps fresh, stale, expired, and unrecognized states to serve or revalidate behavior
Vinext memory-handler expiration Returns expired entries with cacheState: "expired", which exposed the previous negative-state test
Vinext use cache read path Applies draft bypass, explicit cache disposition, metadata propagation, and isolated background refresh
Vinext unstable_cache read path Uses the same cache disposition for persistent function-cache reads
Vinext minimal refresh context Provides disabled draft mode and retains supported cache inputs without copying request headers or cookies
Vinext shared scheduler Owns isolate-wide deduplication, cleanup, rejection handling, and runtime lifetime attachment

Valid stale shared cache entries currently fall through to the miss path, so the triggering request waits for regeneration even though the entry remains usable until expiry.

Return stale data immediately and coordinate one background refresh per cache key. Keep prerender refreshes in the foreground, isolate regeneration metadata while preserving supported cache-scope inputs, and attach guarded work to the request lifetime when available.
Runtime ISR regeneration could store a fresh route artifact containing stale use-cache data because stale hits always detached their refresh outside build-time prerendering. That ignored the request policy already used by unstable_cache to require fresh dependencies while regenerating an artifact.\n\nUse one cache revalidation mode for both APIs, isolate background refresh request state behind a named boundary, and share the per-key scheduler lifecycle. The runtime ISR regression now proves a foreground stale read blocks, stores the fresh value, and registers no background work.
Draft requests could return stale published data and regenerate preview output into the persistent use-cache entry because the shared handler path did not consult the active draft state.\n\nExecute public and remote use-cache callbacks directly inside their normal cache scope when draft mode is enabled. Background refreshes no longer inherit headers or cookies, since persistent regeneration now runs only for non-draft requests.
Background use-cache regeneration lost its headers context entirely, so cached
callbacks that read draftMode().isEnabled failed after a stale hit even though
that read is supported inside cache scopes.

Give detached refreshes a disabled draft-mode provider with empty header and
cookie stores. This keeps preview state isolated while allowing the callback to
regenerate and persist the fresh entry.
Expired values returned by cache handlers were treated as ordinary hits because
only stale entries received special handling. Memory-backed entries could
therefore remain visible indefinitely without triggering regeneration.

Choose an explicit read action from the entry state and request revalidation
policy. Stale values remain eligible for background refresh during ordinary
requests, while expired and unrecognized states regenerate in the foreground
for both unstable_cache and "use cache".
@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared d6caf53 against base 22f106e using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 131.9 KB 132.4 KB ⚫ +0.3%
Client entry size (gzip) vinext 119.3 KB 119.7 KB ⚫ +0.3%
Dev server cold start vinext 2.72 s 2.71 s ⚫ -0.1%
Production build time vinext 2.85 s 2.84 s ⚫ -0.4%
RSC entry closure size (gzip) vinext 103.2 KB 103.3 KB ⚫ +0.1%
Server bundle size (gzip) vinext 177.5 KB 177.8 KB ⚫ +0.2%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@pkg-pr-new

pkg-pr-new Bot commented Jul 23, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2682
npm i https://pkg.pr.new/create-vinext-app@2682
npm i https://pkg.pr.new/@vinext/types@2682
npm i https://pkg.pr.new/vinext@2682

commit: d6caf53

A stale unstable_cache entry served during an App Router request scheduled
its background refresh inside the triggering request's unified context. The
detached callback could therefore mutate that request's tag, observation,
and fetch containers after the response was already being finalized, and
nested stale function-cache reads still saw the request's "background"
policy, so a refreshed outer entry could be assembled from stale nested
data. Run the refresh through the same isolated synthetic work unit that
"use cache" refreshes use, whose foreground mode forces nested dependencies
fresh before the regenerated entry is stored.

Also rename cacheRevalidationMode to functionCacheRevalidationMode: patched
fetch keeps its separate refreshStaleFetchesInForeground flag whose default
is fail-open, so the field only governs the function caches and the broader
name misrepresented that boundary. Converging both onto one request-level
freshness policy is deliberate follow-up work, documented on the type.
The functionCacheRevalidationMode comment promised the fetch-policy
convergence as tracked follow-up without saying where. Point it at
issue cloudflare#2685, which records the per-path audit and the Pages Router
semantics decision that keeps the convergence out of this PR.
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 277d7885a1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/vinext/src/shims/cache-runtime.ts
Comment thread packages/vinext/src/server/app-rsc-handler.ts Outdated
…ound

Address two review findings on the "use cache" stale-while-revalidate path:

- Propagate a served data-cache HIT entry's cache-control into the enclosing
  cache scope's lifeConfigs, not only the request store. On a hit the child
  function never runs, so this is the only place its (shorter) lifetime can
  constrain an outer "use cache" entry. Without it, an outer miss embedding a
  stale child stored the outer entry under the default 900s window while the
  child refreshed separately, serving stale nested data past the child's own
  expire. Mirrors the tag propagation already done on the hit path.

- Seed foreground function-cache revalidation for prerender requests in
  createAppRscHandler. VINEXT_PRERENDER=1 requests bake a static artifact, so a
  stale persistent entry must be refreshed in the foreground rather than served
  stale and refreshed in the background. Matches the static-generation dispatch
  paths.

Both are covered by regressions that fail against the prior behavior.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

"use cache": serve stale entries while revalidating in background (Next.js #74882 / #92636)

1 participant