Skip to content

fix(app-router): honor cacheLife stale on the client router#2708

Open
NathanDrake2406 wants to merge 10 commits into
cloudflare:mainfrom
NathanDrake2406:fix/client-stale-time-from-cache-life
Open

fix(app-router): honor cacheLife stale on the client router#2708
NathanDrake2406 wants to merge 10 commits into
cloudflare:mainfrom
NathanDrake2406:fix/client-stale-time-from-cache-life

Conversation

@NathanDrake2406

@NathanDrake2406 NathanDrake2406 commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Overview

Goal Make the client router honor cacheLife.stale, the client-reuse dimension of a cache profile that vinext computed and then discarded
Core change One projection, resolveClientStaleTimeSeconds, carries the completed render's minimum stale onto every artifact that outlives the render: the ISR entry, the prerender seed, the initial-HTML done-script, and the regenerated entry. Cache hits replay it as x-nextjs-stale-time; both client caches bound reuse by it
Key boundary stale is a property of a completed render (aggregation is minimum-wins, and use cache scopes keep registering while the stream is consumed), so it travels only on completion-time channels, never in streaming headers
Expected impact A subtree declaring cacheLife("seconds") (stale: 30) is reused by the browser for at most 30s instead of the 5-minute client cache TTL

Why

cacheLife profiles carry three independent numbers. revalidate and expire govern server and shared caches; stale is the contract with the client router: how long the browser may reuse route output without asking the server. vinext min-reduces stale across all use cache scopes of a request and then projected it out of every consumer, so the only freshness signal a browser ever saw was the build-time experimental.staleTimes constant. Request-scoped declarations had no observable effect on navigation freshness.

The constraint that shapes the design: a streaming response's headers cannot carry its own resolved cacheLife. Headers commit when the Flight stream is created; child Server Components register use cache scopes while the stream is consumed. Because aggregation is minimum-wins, a value read at header time is not approximately right, it is unbounded too large. The value is therefore read only at completion time and attached to durable artifacts:

Area Principle / invariant What this PR changes
ISR write (app-page-cache-finalizer.ts) The cache-write closure runs after the captured stream drains, so its read is the completed minimum CacheControlMetadata gains stale?; the write policy persists it via the new AppPageCacheSetter policy object (isrSetAppPage)
Cache-hit replay (app-page-cache.ts) Replay must claim exactly what the producing render claimed, and Next.js replays the stored header verbatim on every hit HIT and STALE responses emit x-nextjs-stale-time from entry metadata, unaged and unclamped; expire stays a serve-side ceiling (expired entries are blocking misses)
Background regeneration (app-page-cache-render.ts) A regen must not silently widen client reuse The real regen producer carries stale on its cacheControl into the refreshed entry
Prerender seed (build/prerender.ts, seed-cache.ts) Seeded entries must make the same claim a runtime render would persist VINEXT_PRERENDER_CACHE_LIFE_HEADER carries stale into the route results; PrerenderRouteResult declares it and writePrerenderIndex copies it into vinext-prerender.json, where seeding reads it
Cloudflare KV backend (kv-data-adapter.runtime.ts, prerender-kv-populate.ts) The primary deployment target must round-trip the same metadata the in-memory backend does set() persists cacheControl.stale (validated on read); deploy-time KV population carries route.stale into bulk-import entries
Initial HTML (app-ssr-stream.ts, app-page-render.ts) The done-script is emitted by finalize() after the full RSC drain, so it is a completion-time channel The metadata getter peeks the request-scoped cacheLife and ships staleTimeSeconds; hydration seeds the visited-response entry from it, identically in dev and prod
Data-cache propagation (shims/cache-runtime.ts) Advertised freshness must not depend on data-cache temperature The use cache entry write persists stale; a warm hit re-registers it on the request scope and, when nested inside another use cache, pushes it into the enclosing scope's lifeConfigs exactly like the MISS path — so the outer entry keeps the child's claim once both go warm
Cold RSC fetch (app-page-render.ts, shims/navigation.ts) An unresolved claim, once floored, can never license less than 30s Cold cacheable responses stream with X-Vinext-Stale-Time-Pending: 1; both client caches cap such a response at the 30s floor instead of their fallback TTLs

Two normalization rules, documented at the resolver: an absent stale is never synthesized from revalidate or expire (the default profile would license ~136 years of reuse), and no other field constrains it (seconds is { stale: 30, revalidate: 1, expire: 60 } on purpose; Next.js floors the replayed value at 30s client-side, so client reuse may overshoot expire by design).

What changed

Scenario Before After
Warm ISR hit, HTML or RSC Configured staleTimes / 5-minute fallback Entry's own stale replayed verbatim on both artifacts; Cache-Control unchanged
Initial HTML view, dev and prod, cached or not Config signal only Done-script carries the completed render's stale; a dynamic render carries both signals and the client takes the minimum
Prerendered route Fallback Seeded entries replay the prerender's resolved stale
Entry refreshed by background regeneration n/a The regenerating render's claim is preserved on the refreshed entry
Cold cacheable RSC fetch navigation 5-minute fallback Capped at the 30s floor via the pending marker until the entry is warm
Cold RSC fetch that completes dynamic mid-stream 5-minute fallback Capped at 30s; the ISR write is skipped, so the next request re-renders. Known limit: a cold stream cannot know its outcome, so staleTimes.dynamic below 30s is not enforced on this one response (see risk)
cacheLife({ stale: 5 }) Fallback TTLs Held 30s in both client caches (one floor rule, mirroring Next.js getStaleTimeMs), whether the route arrived via prefetch or cold navigation
Warm hit on the Cloudflare KV backend stale dropped at serialization; fallback cacheControl.stale round-trips through KV and deploy-time bulk import
Nested use cache, inner entry warm Outer entry stored with its default lifetime; inner claim lost once outer goes warm Inner HIT constrains the outer entry's stored lifetime like a MISS does
Streamed responses, shared caches No stale anywhere Unchanged: stale never enters Cache-Control, and cold responses stay non-blocking (#961)
Maintainer review path
  1. packages/vinext/src/utils/cache-control-metadata.ts: the shared projection and its two normalization rules; every emitter goes through it.
  2. packages/vinext/src/server/app-page-cache-finalizer.ts: why the write-time read is sound (post-drain) and how stale enters the entry.
  3. packages/vinext/src/server/app-page-cache.ts: verbatim replay on HIT/STALE and the regeneration policy.
  4. packages/vinext/src/server/app-page-render.ts: the RSC branch (pending marker paired with the dynamic bound) and the done-script metadata getter; the negative guard test explains why streaming headers can never carry the value.
  5. packages/vinext/src/shims/navigation.ts: the client resolver (min of both lattices, uniform 30s floor, pending handling) and snapshot/replay round-trip.
  6. packages/vinext/src/server/app-browser-entry.ts: hydration seeding and the visited-response store.
  7. packages/vinext/src/shims/cache-runtime.ts, build/prerender.ts, server/seed-cache.ts: the propagation links that would otherwise silently drop the value.
Validation
  • Write path: { stale: 30, revalidate: 1, expire: 60 } persists 30 (revalidate does not clamp); { stale: 300, expire: 45 } persists 300 (expire never clamps); a streaming render's headers advertise nothing even with a page-level claim (regression guard for the header-time approach).
  • Replay: verbatim on HIT and STALE, no aging; default-shaped entries emit no header.
  • Regeneration: the real producer carries a mid-render claim onto its cacheControl (tests/app-page-cache-render.test.ts; fails without the producer fix).
  • Cold path: the non-blocking streaming test pins the pending marker; the late-dynamic scenario (capture-eligible, dynamic usage mid-stream, ISR write skipped) pins the 30s cap and the skipped write; the segment-cache-client-params compat E2E pins full prefetch reuse of statically-prefetchable dynamic-param routes.
  • Client: min-combination, uniform floor, pending cap, and header round-trips in both cache suites.
  • KV: cacheControl.stale round-trip through KVCacheHandler and deploy-time bulk-import entries; writePrerenderIndex regression test pins stale in vinext-prerender.json; the nested-HIT test fails without the lifeConfigs propagation fix (verified red).
  • Ran vp check and the affected suites locally; CI runs the full suite and all E2E projects.
Risk / compatibility
  • Persisted format: CacheControlMetadata.stale is optional; entries written before this change simply emit no header and clients keep their configured fallback.
  • Wire: two additive response headers (x-nextjs-stale-time, matching Next.js, and the vinext-internal X-Vinext-Stale-Time-Pending).
  • Known limit: with an edge CDN adapter whose set() is a no-op, the streamed response is the cached artifact, so edge HITs replay the pending marker and RSC navigations stay capped at the conservative 30s floor instead of a wider completed claim. Attaching the completed claim would require blocking the stream (rejected in fix(isr): honor route expire ceilings #961); dropping the marker would widen reuse beyond the declaration. The initial-HTML path is unaffected — the done-script travels in the body.
  • Known limit: a render that turns dynamic only during streaming still carries the pending marker, so its epoch-cold response is reused up to 30s even when staleTimes.dynamic is lower. Bounding every pending response by staleTimes.dynamic instead was tried and reverted: it destroys prefetch reuse of statically-prefetchable dynamic-param routes (the segment-cache-client-params compat test), because a cold stream cannot distinguish the two outcomes. Eliminating the window entirely requires blocking cold renders, rejected in fix(isr): honor route expire ceilings #961.
  • Deliberate divergences from Next.js, each documented in code: the client combines the config and cacheLife signals by minimum instead of header-wins (vinext keeps the config signal as a separate carrier), and the done-script advertises the claim in dev where Next.js emits nothing.
  • Shared caches: stale never enters Cache-Control; expire handling is unchanged.
Non-goals
  • Blocking cold cacheable renders to emit the resolved value in headers: Next.js does this for ISR misses, but fix(isr): honor route expire ceilings #961 deliberately keeps vinext's cold responses streaming with a background waitUntil write. The pending marker bounds the residual window instead.
  • An in-band trailing transport (Next.js's StaleTimeIterable payload field): it exists only in cacheComponents staged renders, where cache scopes settle before the render task queue drains. In vinext's lazy streaming model the iterable could never be closed (the stream cannot end while it is open, and completion is observable only via stream end).
  • Tag-based invalidation, SWR serve-and-refresh, and back/forward traversal freshness, which keeps MAX_TRAVERSAL_CACHE_TTL bfcache semantics.

References

Reference Why it matters
Next.js app-render.tsx (applyMetadataFromPrerenderResult) The header is emitted only from fully awaited static-generation renders; streamed dynamic renders carry nothing
Next.js app-page-runtime.ts cached-header replay Stored headers, including the stale time, are re-emitted verbatim on every hit; the basis for vinext's unaged replay
Next.js server/app-render/stale-time.ts The StaleTimeIterable transport this PR deliberately does not port (see non-goals)
#961 Established that cold ISR responses stream and must not block on cache metadata

No changeset: this repo generates them from merged SHAs via scripts/create-changeset.mts.

`cacheLife` profiles carry three independent numbers, and `stale` is the
client-router dimension: how long the browser may reuse cached route output
without asking the server. vinext aggregated it correctly (`resolveCacheLife`
min-reduces all three; the request scope accumulates min-wins) and then
projected it away at every consumer, so the only staleness a browser ever saw
was `dynamicStaleTimeSeconds` from `experimental.staleTimes` — a build-time
constant unrelated to the cached subtrees that produced the render. A subtree
declaring `cacheLife("seconds")` (stale: 30) was held for the full 5-minute
visited-response TTL.

Carry the resolved `stale` to the client on `x-nextjs-stale-time` (matching
Next.js's `NEXT_ROUTER_STALE_TIME_HEADER`) and combine it with the config
value by taking the minimum, so neither min-wins lattice overrides the other.
The 30s prefetch floor applies to the new value too.

Two normalization rules, both deliberate:

- An absent `stale` is never synthesized from `revalidate`/`expire`. The
  `default` profile has no `stale` and an `expire` of ~136 years, so deriving
  one would license session-long reuse without a refresh.
- The three numbers are not assumed to be ordered — `seconds` is
  `{ stale: 30, revalidate: 1, expire: 60 }` — so `revalidate` never
  constrains `stale`. Only the hard `expire` ceiling does.
The previous commit derived `x-nextjs-stale-time` from `peekRequestCacheLife()`
at response-construction time. That read happens after `probeAppPageBeforeRender`
but before the RSC stream is consumed, and the probe only awaits the page
component's own async result — it does not render the returned tree. Any
`use cache` scope in a child Server Component registers later, during stream
consumption, so the header described the probe rather than the output.

Because `cacheLife` aggregation is minimum-wins, missing one late scope is
enough to make the value wrong, and "it can only shorten" did not hold: with no
`dynamicStaleTimeSeconds` present (the usual case for `use cache` pages, which
are not dynamic renders), a peeked `stale: 300` widened the prefetch window from
`PREFETCH_CACHE_TTL` (30s) to 300s — 10x longer than today, in the direction the
change set out to fix.

Carry the value on the cache entry instead. The ISR write path reads the
request-scoped accumulation via the consuming `getRequestCacheLife()` inside the
cache-write closure, which runs after the captured stream has drained, so it
observes the completed render's minimum. `CacheControlMetadata` gains `stale`,
`isrSet` persists it, and `buildAppPageCachedResponse` re-advertises it on hits.
Prerender seeds carry it through `VINEXT_PRERENDER_CACHE_LIFE_HEADER` and the
prerender manifest so a seeded entry makes the same claim a runtime render would.

Two links that would otherwise silently drop the claim are closed with it: the
`use cache` entry write now persists `stale`, and `recordRequestScopedCacheControl`
re-registers it on a data-cache hit — without both, a page's advertised freshness
would depend on data-cache temperature rather than on what it declared.

Streaming responses now advertise nothing and leave the client on its configured
`experimental.staleTimes`, which is the honest answer for a value that is not yet
known when headers are committed. Covering fresh streaming renders needs a
render-completion signal the streaming RSC path does not have today; Next.js
solves it by streaming an `AsyncIterable<number>` in the RSC payload
(`app-render.tsx` `baseResponse.s`, closed after the render settles), which is
the natural follow-up.

The client-side combination logic is unchanged: both stale signals are still
min-reduced, absent `stale` is never synthesized from `revalidate`/`expire`,
`revalidate` never clamps `stale`, and `expire` still caps it.
The previous round persisted the completed render's cacheLife stale onto
the ISR entry and replayed it on cache hits, which left the value missing
or drifting wherever the entry's lifetime diverged from the render's:

- Fresh streaming renders advertised nothing, so the first visitor of any
  route (and every dev render, since dev writes no ISR entry) stayed on
  the configured staleTimes despite a declared cacheLife. The done-script
  emitted by the RSC embed transform's finalize() runs only after the
  full RSC stream has drained, so it can carry the completed render's
  minimum where streaming headers cannot. Emit the peeked request-scoped
  cacheLife there and seed the hydration visited-response entry from it.
  This also makes the client's min-combination of the config and
  cacheLife signals reachable: a dynamic render with use cache subtrees
  now legitimately carries both.

- The expire clamp bounded the value but not the elapsed window: an
  entry of { stale: 30, expire: 60 } hit at age 59s replayed a 30s reuse
  window reaching 29s past expire. Age the serve-time clamp with the
  entry's lastModified so only the remaining window is advertised.

- The two client caches applied different floors: prefetch entries
  floored a cacheLife stale at 30s while cold navigations honored it
  verbatim, so the same declaration produced two behaviors keyed on
  whether a prefetch fired first. Floor the cacheLife signal once in the
  shared resolver, mirroring Next.js getStaleTimeMs, before the min so
  it can never raise the config-derived bound.

Mechanically, the app-page cache setter's six-position signature
(declared identically in four modules) collapses into one exported
AppPageCacheSetter taking an AppPageCacheWritePolicy object, with
isrSetAppPage adapting to the shared positional isrSet, which stays
unchanged for the Pages Router and route handlers.
The cold RSC fetch gap is a design decision, not a pending follow-up:
Next.js's AsyncIterable stale transport only works under staged rendering
(cacheComponents), where cache scopes settle before the render task queue
drains — in vinext's lazy streaming model the iterable could never close.
Next.js's plain-mode mechanism is a blocking cold render, which cloudflare#961
deliberately rejected to keep ISR page streams unblocked. Assert the
resulting no-header contract on the streaming-response test.

Also reword the write-policy expire comment: a cacheLife-declared expire
replaces the config expireTime fallback (Next.js precedence), it is not
min-merged with a route-level ceiling — no such ceiling exists outside
cacheLife.
Three fixes from review round 3:

Background regeneration dropped the regenerating render's cacheLife stale:
renderAppPageCacheArtifacts returned only { revalidate, expire }, so
resolveRegeneratedAppPageCachePolicy could never receive the stale it was
built to preserve and the first regen silently widened client reuse back
to the configured fallback. The producer now carries it, with a
real-producer regression test the mocked-cacheControl tests could not
provide.

The age-aware expire clamp is removed. Composed with the client's 30s
floor it delivered neither contract (a clamped 1 re-floored to 30), and
cached HTML replayed the unclamped done-script value regardless. Next.js
stores the stale header at generation and replays it verbatim on every
hit; vinext now does the same, keeping expire a serve-side ceiling.

Cold cacheable RSC responses stream before their cacheLife resolves
(cloudflare#961), which left them on the 300s client fallback — reproducing the
headline bug for the first request of every entry epoch. They now carry
X-Vinext-Stale-Time-Pending, and both client caches bound such responses
at the 30s floor: the unresolved claim, once floored, could never
license less.
The pending marker meant 'capture was attempted', but the client read it
as 'a cacheLife claim exists'. Capture eligibility is decided before the
lazy stream runs, so a late request-API read can make the completed
render dynamic — the finalizer skips the ISR write and no claim ever
exists, yet the marker granted 30 seconds of reuse even under
staleTimes.dynamic: 0.

Pending responses now carry the configured dynamic stale time (including
0) and the client takes the minimum, so an unresolved response never
receives a wider window than the dynamic bound.
@pkg-pr-new

pkg-pr-new Bot commented Jul 25, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 83296eb

@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 83296eb against base 05eee9f 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 132.4 KB 132.6 KB ⚫ +0.2%
Client entry size (gzip) vinext 119.7 KB 119.9 KB ⚫ +0.2%
Dev server cold start vinext 2.78 s 2.79 s ⚫ +0.4%
Production build time vinext 3.04 s 3.04 s ⚫ +0.0%
RSC entry closure size (gzip) vinext 103.3 KB 103.5 KB ⚫ +0.2%
Server bundle size (gzip) vinext 177.8 KB 178.3 KB ⚫ +0.3%

View detailed results and traces

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

Pairing the pending marker with the configured dynamic stale time broke
the segment-cache-client-params compat test: dynamic-param routes
prefetch with no minimum TTL, so the paired 0 default made every cold
prefetch entry expire instantly and navigations refetched routes that
Next.js serves entirely from a static prefetch.

A cold stream cannot distinguish a render that will resolve static from
one that turns dynamic mid-stream, and bounding both by staleTimes.dynamic
sacrifices the guaranteed-correct case for the ambiguous one. Pending
responses go back to the 30s floor cap; the late-dynamic exposure
(one epoch-cold response, at most 30s) is documented as the price of
non-blocking cold renders (cloudflare#961).
@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: 437f640819

ℹ️ 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/server/isr-cache.ts
Comment thread packages/vinext/src/build/prerender.ts
Comment thread packages/vinext/src/server/app-page-render.ts
Comment thread packages/vinext/src/shims/cache-runtime.ts
@NathanDrake2406
NathanDrake2406 marked this pull request as draft July 25, 2026 17:00
…d nested cache hits

- writePrerenderIndex now copies `stale` into vinext-prerender.json so
  seedMemoryCacheFromPrerender actually receives it
- KVCacheHandler.set() and buildPrerenderKVPairs persist cacheControl.stale
  so warm hits on the Cloudflare KV backend replay the producing render's
  claim; validateCacheEntry accepts the field
- a nested use cache HIT pushes its stored lifetime into the enclosing
  cache context's lifeConfigs, mirroring the MISS path, so the outer entry
  keeps the child's stale claim once the outer goes warm
The added modules ride in every consumer build environment; the review-grade
rationale lives in the PR body, the code keeps one-line constraints.
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Hooray!

Reviewed commit: 83296eb23f

ℹ️ 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".

@NathanDrake2406
NathanDrake2406 marked this pull request as ready for review July 25, 2026 17:36
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.

1 participant