Skip to content

Cached Navigations: Cache visited fully static pages in the segment cache#90306

Merged
unstubbable merged 21 commits intocanaryfrom
hl/cached-navs-2
Mar 4, 2026
Merged

Cached Navigations: Cache visited fully static pages in the segment cache#90306
unstubbable merged 21 commits intocanaryfrom
hl/cached-navs-2

Conversation

@unstubbable
Copy link
Contributor

@unstubbable unstubbable commented Feb 21, 2026

When a fully static page is loaded (via initial HTML or client-side navigation), the segments are now written into the segment cache so subsequent navigations can be served entirely from cache without server requests.

Initial HTML: The RSC payload inlined in the HTML is written during hydration via getStaleAt + writeStaticStageResponseIntoCache with isResponsePartial: false, using FetchStrategy.Full since all segments are present. A StaleTimeIterable (s field) is included in the InitialRSCPayload during the Cache Components prerender path to provide the stale time.

Navigation: The isResponsePartial flag from the prepended byte (stripped by processFetch) is now also used to determine how segments from writeStaticStageResponseIntoCache are cached. For fully static routes (isResponsePartial: false), segments are written as non-partial so no dynamic follow-up is needed.

The route tree used for cache writes is now always derived from the Flight data itself (via convertServerPatchToFullTree), rather than from a pre-existing route cache entry. This guarantees the tree stays in sync with the response and avoids key mismatches between the static stage tree and the per-segment prefetch tree for parallel route slots.

When writing the head into the cache, isHeadPartial from the server is overridden to false for non-partial responses, but only when Cache Components is enabled. This corrects the server's conservative isPossiblyPartialHead marking during static generation. Without Cache Components, the server already sends the correct value. Responses without a recognized marker byte (e.g. regular navigations that don't go through the prerender path) are conservatively treated as partial.

Partially static pages (where the static stage needs to be extracted via byte-level truncation of the initial HTML Flight stream) will be handled in a follow-up.

@nextjs-bot
Copy link
Collaborator

nextjs-bot commented Feb 21, 2026

Tests Passed

@nextjs-bot
Copy link
Collaborator

nextjs-bot commented Feb 21, 2026

Stats from current PR

🔴 1 regression

Metric Canary PR Change Trend
node_modules Size 476 MB 476 MB 🔴 +223 kB (+0%) ▁▁▁▁▁
📊 All Metrics
📖 Metrics Glossary

Dev Server Metrics:

  • Listen = TCP port starts accepting connections
  • First Request = HTTP server returns successful response
  • Cold = Fresh build (no cache)
  • Warm = With cached build artifacts

Build Metrics:

  • Fresh = Clean build (no .next directory)
  • Cached = With existing .next directory

Change Thresholds:

  • Time: Changes < 50ms AND < 10%, OR < 2% are insignificant
  • Size: Changes < 1KB AND < 1% are insignificant
  • All other changes are flagged to catch regressions

⚡ Dev Server

Metric Canary PR Change Trend
Cold (Listen) 507ms 508ms ▁▁▁▁▃
Cold (Ready in log) 491ms 488ms ▁▁▁▁▂
Cold (First Request) 1.068s 1.059s ▁▂▂▂▁
Warm (Listen) 508ms 508ms ▁▁▁▁▃
Warm (Ready in log) 488ms 486ms ▁▁▁▁▃
Warm (First Request) 397ms 394ms ▁▁▁▁▃
📦 Dev Server (Webpack) (Legacy)

📦 Dev Server (Webpack)

Metric Canary PR Change Trend
Cold (Listen) 455ms 455ms ▁▁▁▁▁
Cold (Ready in log) 437ms 437ms ▃▄▃▁▄
Cold (First Request) 1.942s 1.973s ▂▂▂▁▂
Warm (Listen) 456ms 455ms ▁▁▁▁▁
Warm (Ready in log) 436ms 438ms ▃▃▃▁▃
Warm (First Request) 1.930s 1.956s ▂▂▁▁▁

⚡ Production Builds

Metric Canary PR Change Trend
Fresh Build 4.681s 4.672s ▁▁▁▁▄
Cached Build 4.649s 4.705s ▁▁▁▁▄
📦 Production Builds (Webpack) (Legacy)

📦 Production Builds (Webpack)

Metric Canary PR Change Trend
Fresh Build 13.988s 14.006s ▁▁▁▂▁
Cached Build 14.059s 14.164s ▁▁▁▁▁
node_modules Size 476 MB 476 MB 🔴 +223 kB (+0%) ▁▁▁▁▁
📦 Bundle Sizes

Bundle Sizes

⚡ Turbopack

Client

Main Bundles: **401 kB** → **401 kB** ⚠️ +199 B

80 files with content-based hashes (individual files not comparable between builds)

Server

Middleware
Canary PR Change
middleware-b..fest.js gzip 769 B 764 B
Total 769 B 764 B ✅ -5 B
Build Details
Build Manifests
Canary PR Change
_buildManifest.js gzip 453 B 451 B
Total 453 B 451 B ✅ -2 B

📦 Webpack

Client

Main Bundles
Canary PR Change
5528-HASH.js gzip 5.54 kB N/A -
6280-HASH.js gzip 58.7 kB N/A -
6335.HASH.js gzip 169 B N/A -
912-HASH.js gzip 4.59 kB N/A -
e8aec2e4-HASH.js gzip 62.6 kB N/A -
framework-HASH.js gzip 59.7 kB 59.7 kB
main-app-HASH.js gzip 255 B 253 B
main-HASH.js gzip 39.1 kB 39.1 kB
webpack-HASH.js gzip 1.68 kB 1.68 kB
262-HASH.js gzip N/A 4.59 kB -
2889.HASH.js gzip N/A 169 B -
5602-HASH.js gzip N/A 5.55 kB -
6948ada0-HASH.js gzip N/A 62.6 kB -
9544-HASH.js gzip N/A 59.8 kB -
Total 232 kB 233 kB ⚠️ +1.02 kB
Polyfills
Canary PR Change
polyfills-HASH.js gzip 39.4 kB 39.4 kB
Total 39.4 kB 39.4 kB
Pages
Canary PR Change
_app-HASH.js gzip 194 B 194 B
_error-HASH.js gzip 183 B 180 B 🟢 3 B (-2%)
css-HASH.js gzip 331 B 330 B
dynamic-HASH.js gzip 1.81 kB 1.81 kB
edge-ssr-HASH.js gzip 256 B 256 B
head-HASH.js gzip 351 B 352 B
hooks-HASH.js gzip 384 B 383 B
image-HASH.js gzip 580 B 581 B
index-HASH.js gzip 260 B 260 B
link-HASH.js gzip 2.51 kB 2.51 kB
routerDirect..HASH.js gzip 320 B 319 B
script-HASH.js gzip 386 B 386 B
withRouter-HASH.js gzip 315 B 315 B
1afbb74e6ecf..834.css gzip 106 B 106 B
Total 7.98 kB 7.98 kB ✅ -1 B

Server

Edge SSR
Canary PR Change
edge-ssr.js gzip 125 kB 125 kB
page.js gzip 254 kB 256 kB
Total 379 kB 381 kB ⚠️ +1.35 kB
Middleware
Canary PR Change
middleware-b..fest.js gzip 617 B 617 B
middleware-r..fest.js gzip 156 B 155 B
middleware.js gzip 43.9 kB 43.9 kB
edge-runtime..pack.js gzip 842 B 842 B
Total 45.5 kB 45.5 kB ✅ -68 B
Build Details
Build Manifests
Canary PR Change
_buildManifest.js gzip 715 B 718 B
Total 715 B 718 B ⚠️ +3 B
Build Cache
Canary PR Change
0.pack gzip 4.06 MB 4.07 MB 🔴 +8.1 kB (+0%)
index.pack gzip 103 kB 102 kB
index.pack.old gzip 103 kB 103 kB
Total 4.26 MB 4.27 MB ⚠️ +6.99 kB

🔄 Shared (bundler-independent)

Runtimes
Canary PR Change
app-page-exp...dev.js gzip 321 kB 321 kB
app-page-exp..prod.js gzip 170 kB 170 kB
app-page-tur...dev.js gzip 320 kB 321 kB
app-page-tur..prod.js gzip 170 kB 170 kB
app-page-tur...dev.js gzip 317 kB 318 kB
app-page-tur..prod.js gzip 168 kB 168 kB
app-page.run...dev.js gzip 317 kB 318 kB
app-page.run..prod.js gzip 168 kB 169 kB
app-route-ex...dev.js gzip 70.8 kB 70.8 kB
app-route-ex..prod.js gzip 49.3 kB 49.3 kB
app-route-tu...dev.js gzip 70.9 kB 70.9 kB
app-route-tu..prod.js gzip 49.3 kB 49.3 kB
app-route-tu...dev.js gzip 70.5 kB 70.5 kB
app-route-tu..prod.js gzip 49 kB 49 kB
app-route.ru...dev.js gzip 70.4 kB 70.4 kB
app-route.ru..prod.js gzip 49 kB 49 kB
dist_client_...dev.js gzip 324 B 324 B
dist_client_...dev.js gzip 326 B 326 B
dist_client_...dev.js gzip 318 B 318 B
dist_client_...dev.js gzip 317 B 317 B
pages-api-tu...dev.js gzip 43.2 kB 43.2 kB
pages-api-tu..prod.js gzip 32.9 kB 32.9 kB
pages-api.ru...dev.js gzip 43.2 kB 43.2 kB
pages-api.ru..prod.js gzip 32.9 kB 32.9 kB
pages-turbo....dev.js gzip 52.6 kB 52.6 kB
pages-turbo...prod.js gzip 38.5 kB 38.5 kB
pages.runtim...dev.js gzip 52.6 kB 52.6 kB
pages.runtim..prod.js gzip 38.5 kB 38.5 kB
server.runti..prod.js gzip 62 kB 62 kB
Total 2.83 MB 2.83 MB ⚠️ +4.55 kB
📝 Changed Files (8 files)

Files with changes:

  • app-page-exp..ntime.dev.js
  • app-page-exp..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page.runtime.dev.js
  • app-page.runtime.prod.js
View diffs
app-page-exp..ntime.dev.js
failed to diff
app-page-exp..time.prod.js
failed to diff
app-page-tur..ntime.dev.js
failed to diff
app-page-tur..time.prod.js
failed to diff
app-page-tur..ntime.dev.js
failed to diff
app-page-tur..time.prod.js

Diff too large to display

app-page.runtime.dev.js
failed to diff
app-page.runtime.prod.js
failed to diff
📎 Tarball URL
https://vercel-packages.vercel.app/next/commits/cfc45ebd6cc948b8db06032ecd2e871ad9043e1a/next

@unstubbable unstubbable force-pushed the hl/cached-navs-1 branch 2 times, most recently from 2218724 to d5633db Compare February 22, 2026 12:06
@unstubbable unstubbable force-pushed the hl/cached-navs-2 branch 2 times, most recently from 34ea36d to 457083c Compare February 22, 2026 16:32
@unstubbable unstubbable changed the title Cached Navigations: Cache fully static initial HTML RSC payload Cached Navigations: Cache visited fully static pages in the segment cache Feb 23, 2026
@unstubbable unstubbable force-pushed the hl/cached-navs-2 branch 2 times, most recently from fdaa15f to 5c3b674 Compare February 24, 2026 10:31
@vercel
Copy link
Contributor

vercel bot commented Feb 25, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
next-js Error Error Feb 25, 2026 3:05pm

@unstubbable unstubbable force-pushed the hl/cached-navs-2 branch 3 times, most recently from d56a60a to 7cbc8ac Compare March 3, 2026 12:57
unstubbable and others added 21 commits March 4, 2026 10:24
When a fully static page is loaded, the RSC payload inlined in the HTML
is now written into the client-side segment cache during hydration. This
allows subsequent client-side navigations to the same route to be served
entirely from cache without any server requests, until the stale time
expires.

Server: Include a `StaleTimeIterable` (`s` field) in the
`InitialRSCPayload` during the Cache Components prerender path. The
iterable is tracked on `finalServerPrerenderStore` and closed alongside
the vary params accumulator in the sequential task after prerender
completes.

Client: Thread the stale time and head vary params from the decoded
payload through `createInitialRouterState` into a new
`writeInitialSeedDataIntoCache` function that writes each segment and
the head into the segment cache using the existing
`writeSeedDataIntoCache` / `fulfillEntrySpawnedByRuntimePrefetch`
functions.

Partially static pages (resumed at runtime) are not handled yet. Those
need byte-level truncation of the Flight stream to extract only the
static stage (same as for dynamic RSC requests).
The `isResponsePartial` value from `cacheData` (set by `processFetch`)
defaults to `true` when no marker byte is found. This is correct for
navigation requests (conservative default), but wrong for Full and
LoadingBoundary prefetches which are by definition complete.

Before the `processFetch` refactoring, the prefetch path only read
`isResponsePartial` from `stripIsPartialByte` for PPRRuntime, and
defaulted to `false` for other strategies. The refactoring changed it to
always read from `cacheData`, which overrode the correct `false` default
with an incorrect `true` for non-PPRRuntime strategies.

Fix: only use `cacheData.isResponsePartial` for PPRRuntime prefetches;
default to `false` for Full and LoadingBoundary.
Replace the `isResponsePartial` boolean in
`writeDynamicRenderResponseIntoCache` and `writeSeedDataIntoCache` with
a three-state `ResponseCompleteness` enum that distinguishes:

- `Partial`: segments have dynamic holes (static stage responses,
  PPRRuntime prefetches with `~` marker, postponed responses)
- `Complete`: all segments are complete, but the head may still be
  partial per the server's flag (Full/LoadingBoundary prefetches)
- `FullyStatic`: server explicitly marked the response as fully static
  (marker byte `#`) — both segments and head are complete

The boolean conflated two independent concerns: segment partiality and
whether the server's `isHeadPartial` flag can be overridden. This caused
Full prefetches (which are complete but may have partial heads) to
incorrectly override `isHeadPartial` to `false`, breaking loading
boundary display for pages with dynamic metadata.
The server's `#` marker byte signals that a prerendered response is
fully static (no dynamic follow-up needed). Previously this was used to
control `ResponseCompleteness` during segment cache writes, marking
segments as non-partial. This caused two regressions:

1. Loading boundaries not showing for pages with dynamic metadata (head
   cached as non-partial when it should be partial)
2. Server action refresh breaking parallel routes (default navbar slot
   cached as non-partial, preventing dynamic data from overwriting it)

The root cause: the marker is a route-level signal but was applied as a
segment-level signal at write time.

Now, `isFullyStatic` is stored on the `FulfilledRouteCacheEntry` and
checked at read time in `createCacheNodeForSegment`. When navigating to
a fully static route with cached segment data available, the read path
overrides `isCachedRscPartial` and `isCachedHeadPartial` to skip the
dynamic request. This respects per-segment freshness: once entries
expire, the override no longer applies and a dynamic request is made.

The navigation write path (`writeStaticStageResponseIntoCache`) now
always uses `Partial` completeness. The prefetch write path still uses
`FullyStatic` for the head override since prefetch entries don't
conflict with refresh data.
The three-value `ResponseCompleteness` enum (`Partial`, `Complete`,
`FullyStatic`) was conflating two concerns: segment partiality and the
head override for fully static routes. Now that `isFullyStatic` lives on
the route cache entry, we can use it directly for the head override and
reduce the enum to a simple boolean (same as on `canary`).

- Set `route.isFullyStatic` from the prefetch write path (consistent
  with the navigation and initial HTML paths)
- Use `route.isFullyStatic` for the head partiality override in
  `writeDynamicRenderResponseIntoCache`
- Replace the enum with `isResponsePartial: boolean` in
  `writeDynamicRenderResponseIntoCache` and `writeSeedDataIntoCache`
The server prepends a completeness marker byte to RSC Flight responses.
Previously, both fully static prerenders and complete runtime prefetches
used `#` (0x23), which caused the client to incorrectly set
`route.isFullyStatic` for non-fully-static pages whose runtime render
happened to complete without aborting. This made the segment cache skip
the dynamic follow-up request at navigation time, leaving content from
non-runtime-prefetchable segments missing.

Introduce a third marker byte `*` (0x2a) for complete runtime prefetches
from `finalRuntimeServerPrerender`, reserving `#` for build-time
prerendered fully static pages. The client now only sets
`route.isFullyStatic` when it sees `#`, which is only served for pages
that are genuinely fully static.

The three markers are defined in a shared `ResponseCompletenessMarker`
enum:
- `#` (Static)   — fully static prerender, all segments present
- `*` (Complete)  — complete runtime prefetch, included segments are
                    complete but the response may omit segments
- `~` (Partial)   — partial, contains dynamic holes
Server action redirects to fully static pages include the prerendered
flight data for the redirect target, which has the
`ResponseCompletenessMarker` byte prepended at build time. The server
action reducer passed this response directly to React's
`createFromFetch` without stripping the marker, causing a `Connection
closed` error because the marker byte is not valid RSC Flight data.

Fix by running the response through `processFetch` (which calls
`stripCompletenessMarker`) before passing it to `createFromFetch`. Also
export `processFetch` so it can be reused, and preserve the `redirected`
property on the reconstructed `Response` since the `Response`
constructor does not carry it over from the original.
Delete `writeInitialSeedDataIntoCache` and `processStaticStageResponse`,
replacing their usage with `writeStaticStageResponseIntoCache` and
inline `getStaleAt` calls at each call site. This removes an async cache
write function (all cache writes should be sync, with async computations
at the call site) and eliminates duplicated logic between the initial
HTML and navigation cache write paths.

`writeDynamicRenderResponseIntoCache` and
`writeStaticStageResponseIntoCache` now take `flightData` and `buildId`
as explicit params instead of a full response object, and
`writeStaticStageResponseIntoCache` takes `headVaryParamsThenable`
instead of pre-resolved `headVaryParams`. The `responseHeaders` param is
removed — callers resolve the build ID from the deployment header before
calling.

The build ID check in `writeDynamicRenderResponseIntoCache` is changed
from `buildId !== getNavigationBuildId()` to `buildId && buildId !==
getNavigationBuildId()` so that a missing build ID (e.g. from the
initial HTML path) is treated as permissive rather than as a mismatch.

All `getStaleAt` call sites now use `.then().catch()` instead of
`.then(onFulfilled, onRejected)` so errors from both the stale time
computation and the cache write are caught.

`getStaleAt` is exported for use by external call sites.
The initial HTML of fully static pages should be cached with
`FetchStrategy.Full` and `isResponsePartial = false`, since all segments
are present and no dynamic follow-up is needed. Previously,
`writeStaticStageResponseIntoCache` was used for both navigation
responses and initial HTML, but its `FetchStrategy.PPR` +
`isResponsePartial = true` settings caused issues with mismatching
prefetch rewrites — partial entries triggered unnecessary dynamic
follow-up requests.

`writeStaticStageResponseIntoCache` is now used only for navigation
responses (static stage of PPR).
When writing fully static pages into the segment cache (via navigation
or initial HTML), segments are now written directly as non-partial
instead of using a route-level `isFullyStatic` flag to override
partiality at read time.

Default page segments are skipped during the cache write to prevent
their content from being stored at keys that collide with reused active
slots. During a refresh, the reused slot's lookup would hit these
entries and render the static default content instead of fetching fresh
dynamic data.

The filter handles two cases: segments with `__DEFAULT__` directly (the
implicit `children` parallel route), and `(slot)` virtual wrapper
segments whose `children` child is `__DEFAULT__` (named parallel route
slots like `@navbar`). The `(slot)` wrapper is a build-time construct
from `next-app-loader` that is always exactly one level deep.

`writeInitialFullyStaticResponseIntoCache` is removed in favor of
calling `writeStaticStageResponseIntoCache` with `isResponsePartial:
false`.
The head partial override in `writeDynamicRenderResponseIntoCache` is
now controlled by an explicit `overrideHeadAsNonPartial` parameter
instead of being derived from `isResponsePartial`. Only
`writeStaticStageResponseIntoCache` passes `true` for fully static
responses. This prevents per-segment prefetches and other callers from
incorrectly marking the head as non-partial, which would block dynamic
metadata from being fetched on subsequent navigations to pages with
different metadata.
A runtime prefetch can receive a statically generated response (with the
`Static` completeness marker) for a fully static page. In that case the
head is complete despite the server conservatively marking it as
partial. Without the override, navigating to a previously visited fully
static page would make an unnecessary dynamic request just for the head.
The two boolean parameters `isResponsePartial` and
`overrideHeadAsNonPartial` encoded three valid states but allowed an
invalid fourth combination. They are replaced with a single
`ResponseCompleteness` enum that distinguishes `Partial` (segments have
dynamic holes), `Complete` (all included segments are complete but some
may have been omitted based on the router state tree, or the head may
still be partial), and `Static` (the server confirmed the entire
response is fully static including the head).
Instead of the server prepending a completeness marker byte with three
possible values (Partial, Complete, Static), it now prepends a single
byte indicating whether the response is partial (contains dynamic holes)
or not, as is the case on `canary`. This simplifies the logic on both
the server and client, since the server only needs to distinguish
between partial and non-partial responses, and the client derives all
caching decisions from a single boolean. Responses without a recognized
marker (e.g. regular navigation responses that don't go through the
prerender path) are conservatively treated as partial. The head partial
override in `writeDynamicRenderResponseIntoCache` is guarded by
`process.env.__NEXT_CACHE_COMPONENTS` because the server's defensive
`isPossiblyPartialHead` marking only applies during static generation
with Cache Components enabled. Without Cache Components, the server
already sends the correct `isHeadPartial` value.
When writing a server response into the segment cache, we should not
assume that the returned tree matches the exact shape that was
requested by the client. The server sends back a patch, which must be
applied to the "base" tree (the one included in the request header) to
reconstruct the actual shape of the data. Previously, the route tree
was read from a pre-existing route cache entry, which could diverge
from the response in cases like dynamic rewrites or when handling
parallel routes. This rarely manifested as a bug, but was incorrect
in principle.

This refactors the cache write path so the route tree is always
derived from the Flight data itself, guaranteeing it stays in sync
with the response. For the static stage write path, the seed is
computed from the static stage's own Flight data, since the static
and dynamic stages may produce different trees.

This is a step in an ongoing migration from FlightRouterState to
RouteTree on the client. RouteTree is optimized for cache access
(e.g. pre-computed vary paths), but many places still expect
FlightRouterState, so both representations coexist for now. As a
next step, normalizeFlightData and convertServerPatchToFullTree
should be unified into a single pass, since they are now almost
always called together.
@unstubbable unstubbable merged commit 1d14e93 into canary Mar 4, 2026
286 of 288 checks passed
@unstubbable unstubbable deleted the hl/cached-navs-2 branch March 4, 2026 15:20
sokra pushed a commit that referenced this pull request Mar 6, 2026
…ache (#90306)

When a fully static page is loaded (via initial HTML or client-side
navigation), the segments are now written into the segment cache so
subsequent navigations can be served entirely from cache without server
requests.

Initial HTML: The RSC payload inlined in the HTML is written during
hydration via `getStaleAt` + `writeStaticStageResponseIntoCache` with
`isResponsePartial: false`, using `FetchStrategy.Full` since all
segments are present. A `StaleTimeIterable` (`s` field) is included in
the `InitialRSCPayload` during the Cache Components prerender path to
provide the stale time.

Navigation: The `isResponsePartial` flag from the prepended byte
(stripped by `processFetch`) is now also used to determine how segments
from `writeStaticStageResponseIntoCache` are cached. For fully static
routes (`isResponsePartial: false`), segments are written as non-partial
so no dynamic follow-up is needed.

The route tree used for cache writes is now always derived from the
Flight data itself (via `convertServerPatchToFullTree`), rather than
from a pre-existing route cache entry. This guarantees the tree stays in
sync with the response and avoids key mismatches between the static
stage tree and the per-segment prefetch tree for parallel route slots.

When writing the head into the cache, `isHeadPartial` from the server is
overridden to `false` for non-partial responses, but only when Cache
Components is enabled. This corrects the server's conservative
`isPossiblyPartialHead` marking during static generation. Without Cache
Components, the server already sends the correct value. Responses
without a recognized marker byte (e.g. regular navigations that don't go
through the prerender path) are conservatively treated as partial.

Partially static pages (where the static stage needs to be extracted via
byte-level truncation of the initial HTML Flight stream) will be handled
in a follow-up.

---------

Co-authored-by: Andrew Clark <git@andrewclark.io>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants