Skip to content

fix(app-render): stop prerendered Link headers from accumulating on revalidation - #100960

Merged
unstubbable merged 1 commit into
vercel:canaryfrom
linyiru:fix/app-page-link-header-accumulation
Nov 6, 2026
Merged

unstubbable merged 1 commit into
vercel:canaryfrom
linyiru:fix/app-page-link-header-accumulation

Conversation

@linyiru

@linyiru linyiru commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

What?

With cacheComponents: true, a Link header set in next.config.js headers() (or by middleware) is duplicated on ISR pages that also emit a React preload Link (e.g. an <img fetchPriority="high">), and gains one more copy on every background revalidation:

req 3  Link: </llms.txt>; rel="llms-txt"
       Link: </llms.txt>; rel="llms-txt",</llms.txt>; rel="llms-txt",</hero.png>; rel=preload; as="image"; fetchpriority="high"
req 4  Link: </llms.txt>; rel="llms-txt"
       Link: </llms.txt>; rel="llms-txt",</llms.txt>; rel="llms-txt",</llms.txt>; rel="llms-txt",</hero.png>; rel=preload; ...
...

An on-demand render (cache MISS) of a page not prerendered at build time also returns both the config Link and the preload Link twice.

Why?

prerenderToStream handles React's onHeaders by appending each value to the live response and then storing res.getHeader(name) in metadata.headers, which becomes the APP_PAGE cache entry's headers. That snapshot includes values the render did not produce:

  • the Link from headers() in next.config.js, already on the response before rendering;
  • during a background revalidation, the Link values the serving path replayed from the previous cache entry onto the same response.

app-page-runtime appends the cache entry's headers to every response after the config headers are set, so each regeneration stores, and later serves, one more copy. On a MISS the render has already written the headers to the response, and the same values are appended again from metadata.headers.

How?

prerenderToStream now records only the values it produces (React's onHeaders output and the redirect location) in metadata.headers, and no longer writes them to the response. Every consumer of a prerender result already applies metadata.headers to the response (app-page-runtime for fresh renders and cache hits, the export worker at build time), so the headers reach the client exactly once. A Link from middleware or next.config.js is still merged with React's preload Link (#73431), because the cached value is appended to the response, not set over it.

renderToStream (the dynamic path) is unchanged. It writes onHeaders output to the response only and never stores it in metadata.headers, so it has no cache entry to accumulate into.

Added test/production/app-dir/cache-components-link-header, which configures a Link in headers(), renders a 'use cache' page with a 1s revalidate and a high-priority image, and checks the config Link and the preload Link appear exactly once on every response across four revalidations, for a build-time prerendered page, a prerendered dynamic param, and an on-demand dynamic param. Without the change it fails on webpack (the config Link count climbs 1, 2, 3, 4, 5, and the MISS response has 2 of each). With the change it passes on both webpack and Turbopack.

One behavioural difference: the segment-prefetch cache branch returns before the cached headers are replayed, so an RSC segment-prefetch response that happens to trigger a fresh render no longer carries the preload Link. Such responses have no use for a preload.

Standalone reproduction: https://github.com/linyiru/next-cache-components-link-header-repro

Fixes #100959

muaaziqbalvip added a commit to muaaziqbalvip/next.js that referenced this pull request Oct 30, 2026
- fix(app-render): stop prerendered Link headers from accumulating on
  revalidation. prerenderToStream now records only the headers it
  produces (React onHeaders output, redirect location) in
  metadata.headers instead of snapshotting res.getHeader(name), and no
  longer writes them to the response directly. Consumers already apply
  metadata.headers exactly once. Fixes vercel#100959.
  Upstream: vercel#100960.
- perf(build): use nft's cached readlink when collecting build traces
  by removing the custom readlink override (identical error semantics:
  null on EINVAL/ENOENT/UNKNOWN, cached per path). Fixes
  vercel#100869. Upstream: vercel#99678.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
@github-actions

github-actions Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Tests Passed

Commit: 52e185c
View workflow run

…evalidation

With `cacheComponents`, `prerenderToStream` wrote the headers React emits
through `onHeaders` to the live response and then stored a snapshot of the
response's whole header (`res.getHeader(name)`) in `metadata.headers`, which
becomes the APP_PAGE cache entry's headers.

That snapshot also captured values the render did not produce: a `Link` set
by `headers()` in next.config.js, and, during a background revalidation, the
`Link` values replayed from the previous cache entry. Since the cache entry's
headers are appended to every response after the config headers, each
revalidation added another copy of the config `Link`. On an on-demand render
(cache MISS), the headers written to the response were appended a second time
from `metadata.headers`, so both the config and the preload `Link` appeared
twice.

Record only the values the prerender produces in `metadata.headers` and leave
applying them to the response to the caller, which already does so for both
fresh renders and cache hits. A `Link` set by middleware or next.config.js is
still merged with React's preload `Link`, because the cached value is appended
to the response rather than replacing it.
@unstubbable
unstubbable force-pushed the fix/app-page-link-header-accumulation branch from 0086dc0 to 52e185c Compare November 6, 2026 16:49
@unstubbable
unstubbable merged commit 0755a38 into vercel:canary Nov 6, 2026
124 checks passed
unstubbable added a commit that referenced this pull request Nov 6, 2026
When a navigation reused cached data from a stale partial prerender,
Next.js could render its Flight response while regenerating the prerender
in the background. Both renders had access to the same HTTP response.
A background redirect could change the foreground response's status before
its headers committed and force a document navigation. The shared status
also caused problems in the other direction: a prerender could observe
the stale document response's 404 status and include `noindex` in otherwise
successful regenerated content.

This builds on the header isolation in PR #100960 and extracts the
independent prerender output work from PR #100472. That draft explores a
broader separation between App Router rendering and HTTP transport. We
extract this part so the ownership change can be reviewed and landed
independently of its action, cookie, and lifecycle refactors. The existing
foreground/background scheduling remains intact, as it did in PR #100854.

Prerendering now receives an explicit initial status and owns its status
throughout the render. Normal prerenders start at 200, while error-page
routes establish their intended status. The shared rendering context
exposes only status instead of the whole HTTP response, and prerendering
returns its status through result metadata. Dynamic rendering continues
to use the live response.

Delivery applies the existing HTML and RSC status rules to the selected
result before invoking `onCacheEntry` or `onCacheEntryV2`. Those callbacks
can write and finish the response, so applying status afterward is too
late. They previously relied on prerendering having already changed the
live response. App Page export now consumes the returned metadata directly
instead of constructing request and response mocks to observe those
mutations.

Co-authored-by: Jan Kassens <kassens@vercel.com>
unstubbable added a commit that referenced this pull request Nov 8, 2026
With Cache Components enabled, `next start` served a generic fallback
shell when a navigation to a prerendered path missed a custom cache. The
incomplete RSC response crashed the client with React #412, as reported
in #99861. Static paths that regenerated as partial were also served as
static RSC, without the request data required by navigations and runtime
prefetches.

PR #101125 addresses the navigation miss and returns a 404 when a
navigation still receives incomplete output. The router then reloads the
document. This alternative builds on that work and its regression cases
to keep navigation on the client and fix runtime prefetching too.
Runtime prefetches must resolve the data required at their requested
stage, such as cookies, while allowing valid later-stage holes.

We use the RSC classification introduced in #101309 to choose the miss
policy. On a miss, requests whose RSC was complete at build time wait
for a coalesced prerender. Other classified RSC requests start a
request-specific render while a coalesced background prerender populates
the cache. Explicit blocking policies still take precedence.

After a blocking prerender, the handler checks the actual result before
serving it. It also checks cached entries and fallback results shared
with document requests. If the result is partial, lacks usable RSC, or
comes from a generic fallback shell, each caller renders its own RSC
response. That render runs outside the shared response cache and retains
the original router state and prefetch stage. It can reuse the Resume
Data Cache from postponed state. The response isolation from #100960
and #101295 keeps prerender headers and status separate from the
request-specific response.

This aligns `next start` RSC miss handling with the per-output `onMiss`
behavior planned in nextjs/adapter-vercel#114. We plan an HTML follow-up
as part of the same parity work once its miss policy is agreed.

A separate failure on Vercel affects static paths that regenerate as
partial: RSC requests return `500 INTERNAL_INVALID_RESPONSE`. That
failure predates this change and needs its own follow-up.

Co-authored-by: Arnaud Coral <arnaud@datarooster.io>
unstubbable added a commit that referenced this pull request Nov 9, 2026
With Cache Components enabled, `next start` served a generic fallback
shell when a navigation to a prerendered path missed a custom cache. The
incomplete RSC response crashed the client with React #412, as reported
in #99861. Static paths that regenerated as partial were also served as
static RSC, without the request data required by navigations and runtime
prefetches.

PR #101125 addresses the navigation miss and returns a 404 when a
navigation still receives incomplete output. The router then reloads the
document. This alternative builds on that work and its regression cases
to keep navigation on the client and fix runtime prefetching too.
Runtime prefetches must resolve the data required at their requested
stage, such as cookies, while allowing valid later-stage holes.

We use the RSC classification introduced in #101309 to choose the miss
policy. On a miss, requests whose RSC was complete at build time wait
for a coalesced prerender. Other classified RSC requests start a
request-specific render while a coalesced background prerender populates
the cache. Explicit blocking policies still take precedence.

After a blocking prerender, the handler checks the actual result before
serving it. It also checks cached entries and fallback results shared
with document requests. If the result is partial, lacks usable RSC, or
comes from a generic fallback shell, each caller renders its own RSC
response. That render runs outside the shared response cache and retains
the original router state and prefetch stage. It can reuse the Resume
Data Cache from postponed state. The response isolation from #100960
and #101295 keeps prerender headers and status separate from the
request-specific response.

This aligns `next start` RSC miss handling with the per-output `onMiss`
behavior planned in nextjs/adapter-vercel#114. We plan an HTML follow-up
as part of the same parity work once its miss policy is agreed.

A separate failure on Vercel affects static paths that regenerate as
partial: RSC requests return `500 INTERNAL_INVALID_RESPONSE`. That
failure predates this change and needs its own follow-up.

Co-authored-by: Arnaud Coral <arnaud@datarooster.io>
unstubbable added a commit that referenced this pull request Nov 9, 2026
With Cache Components enabled, `next start` served a generic fallback
shell when a navigation to a prerendered path missed a custom cache. The
incomplete RSC response crashed the client with React #412, as reported
in #99861. Static paths that regenerated as partial were also served as
static RSC, without the request data required by navigations and runtime
prefetches.

PR #101125 addresses the navigation miss and returns a 404 when a
navigation still receives incomplete output. The router then reloads the
document. This alternative builds on that work and its regression cases
to keep navigation on the client and fix runtime prefetching too.
Runtime prefetches must resolve the data required at their requested
stage, such as cookies, while allowing valid later-stage holes.

We use the RSC classification introduced in #101309 to choose the miss
policy. On a miss, requests whose RSC was complete at build time wait
for a coalesced prerender. Other classified RSC requests start a
request-specific render while a coalesced background prerender populates
the cache. Explicit blocking policies still take precedence.

After a blocking prerender, the handler checks the actual result before
serving it. It also checks cached entries and fallback results shared
with document requests. If the result is partial, lacks usable RSC, or
comes from a generic fallback shell, each caller renders its own RSC
response. That render runs outside the shared response cache and retains
the original router state and prefetch stage. It can reuse the Resume
Data Cache from postponed state. The response isolation from #100960
and #101295 keeps prerender headers and status separate from the
request-specific response.

This aligns `next start` RSC miss handling with the per-output `onMiss`
behavior planned in nextjs/adapter-vercel#114. We plan an HTML follow-up
as part of the same parity work once its miss policy is agreed.

A separate failure on Vercel affects static paths that regenerate as
partial: RSC requests return `500 INTERNAL_INVALID_RESPONSE`. That
failure predates this change and needs its own follow-up.

Co-authored-by: Arnaud Coral <arnaud@datarooster.io>
unstubbable added a commit that referenced this pull request Nov 9, 2026
When a navigation reused cached data from a stale partial prerender,
Next.js could render its Flight response while regenerating the
prerender in the background. Both renders had access to the same HTTP
response. A background redirect could change the foreground response's
status before its headers committed and force a document navigation. The
shared status also caused problems in the other direction: a prerender
could observe the stale document response's 404 status and include
`noindex` in otherwise successful regenerated content.

This builds on the header isolation in PR #100960 and extracts the
independent prerender output work from PR #100472. That draft explores a
broader separation between App Router rendering and HTTP transport. We
extract this part so the ownership change can be reviewed and landed
independently of its action, cookie, and lifecycle refactors. The
existing foreground/background scheduling remains intact, as it did in
PR #100854.

Prerendering now receives an explicit initial status and owns its status
throughout the render. Normal prerenders start at 200, while error-page
routes establish their intended status. The shared rendering context
exposes only status instead of the whole HTTP response, and prerendering
returns its status through result metadata. Dynamic rendering continues
to use the live response.

Delivery applies the existing HTML and RSC status rules to the selected
result before invoking `onCacheEntry` or `onCacheEntryV2`. Those
callbacks can write and finish the response, so applying status
afterward is too late. They previously relied on prerendering having
already changed the live response. App Page export now consumes the
returned metadata directly instead of constructing request and response
mocks to observe those mutations.

Co-authored-by: @kassens

Co-authored-by: Jan Kassens <kassens@vercel.com>
unstubbable added a commit that referenced this pull request Nov 9, 2026
With Cache Components enabled, `next start` served a generic fallback
shell when a navigation to a prerendered path missed a custom cache. The
incomplete RSC response crashed the client with React #412, as reported
in #99861. Static paths that regenerated as partial were also served as
static RSC, without the request data required by navigations and runtime
prefetches.

PR #101125 addresses the navigation miss and returns a 404 when a
navigation still receives incomplete output. The router then reloads the
document. This alternative builds on that work and its regression cases
to keep navigation on the client and fix runtime prefetching too.
Runtime prefetches must resolve the data required at their requested
stage, such as cookies, while allowing valid later-stage holes.

We use the RSC classification introduced in #101309 to choose the miss
policy. On a miss, requests whose RSC was complete at build time wait
for a coalesced prerender. Other classified RSC requests start a
request-specific render while a coalesced background prerender populates
the cache. Explicit blocking policies still take precedence.

After a blocking prerender, the handler checks the actual result before
serving it. It also checks cached entries and fallback results shared
with document requests. If the result is partial, lacks usable RSC, or
comes from a generic fallback shell, each caller renders its own RSC
response. That render runs outside the shared response cache and retains
the original router state and prefetch stage. It can reuse the Resume
Data Cache from postponed state. The response isolation from #100960
and #101295 keeps prerender headers and status separate from the
request-specific response.

This aligns `next start` RSC miss handling with the per-output `onMiss`
behavior planned in nextjs/adapter-vercel#114. We plan an HTML follow-up
as part of the same parity work once its miss policy is agreed.

A separate failure on Vercel affects static paths that regenerate as
partial: RSC requests return `500 INTERNAL_INVALID_RESPONSE`. That
failure predates this change and needs its own follow-up.

Co-authored-by: Arnaud Coral <arnaud@datarooster.io>
unstubbable added a commit that referenced this pull request Nov 9, 2026
With Cache Components enabled, `next start` served a generic fallback
shell when a navigation to a prerendered path missed a custom cache. The
incomplete RSC response crashed the client with React #412, as reported
in #99861. Static paths that regenerated as partial were also served as
static RSC, without the request data required by navigations and runtime
prefetches.

PR #101125 addresses the navigation miss and returns a 404 when a
navigation still receives incomplete output. The router then reloads the
document. This alternative builds on that work and its regression cases
to keep navigation on the client and fix runtime prefetching too.
Runtime prefetches must resolve the data required at their requested
stage, such as cookies, while allowing valid later-stage holes.

We use the RSC classification introduced in #101309 to choose the miss
policy. On a miss, requests whose RSC was complete at build time wait
for a coalesced prerender. Other classified RSC requests start a
request-specific render while a coalesced background prerender populates
the cache. Explicit blocking policies still take precedence.

After a blocking prerender, the handler checks the actual result before
serving it. It also checks cached entries and fallback results shared
with document requests. If the result is partial, lacks usable RSC, or
comes from a generic fallback shell, each caller renders its own RSC
response. That render runs outside the shared response cache and retains
the original router state and prefetch stage. It can reuse the Resume
Data Cache from postponed state. The response isolation from #100960
and #101295 keeps prerender headers and status separate from the
request-specific response.

This aligns `next start` RSC miss handling with the per-output `onMiss`
behavior planned in nextjs/adapter-vercel#114. We plan an HTML follow-up
as part of the same parity work once its miss policy is agreed.

A separate failure on Vercel affects static paths that regenerate as
partial: RSC requests return `500 INTERNAL_INVALID_RESPONSE`. That
failure predates this change and needs its own follow-up.

Co-authored-by: Arnaud Coral <arnaud@datarooster.io>
unstubbable added a commit that referenced this pull request Nov 10, 2026
With Cache Components enabled, `next start` served a generic fallback
shell when a navigation to a prerendered path missed a custom cache. The
incomplete RSC response crashed the client with React #412, as reported
in #99861. Static paths that regenerated as partial were also served as
static RSC, without the request data required by navigations and runtime
prefetches.

PR #101125 addresses the navigation miss and returns a 404 when a
navigation still receives incomplete output. The router then reloads the
document. This alternative builds on that work and its regression cases
to keep navigation on the client and fix runtime prefetching too.
Runtime prefetches must resolve the data required at their requested
stage, such as cookies, while allowing valid later-stage holes.

We use the RSC classification introduced in #101309 to choose the miss
policy. On a miss, requests whose RSC was complete at build time wait
for a coalesced prerender. Other classified RSC requests start a
request-specific render while a coalesced background prerender populates
the cache. Explicit blocking policies still take precedence.

After a blocking prerender, the handler checks the actual result before
serving it. It also checks cached entries and fallback results shared
with document requests. If the result is partial, lacks usable RSC, or
comes from a generic fallback shell, each caller renders its own RSC
response. That render runs outside the shared response cache and retains
the original router state and prefetch stage. It can reuse the Resume
Data Cache from postponed state. The response isolation from #100960
and #101295 keeps prerender headers and status separate from the
request-specific response.

This aligns `next start` RSC miss handling with the per-output `onMiss`
behavior planned in nextjs/adapter-vercel#114. We plan an HTML follow-up
as part of the same parity work once its miss policy is agreed.

A separate failure on Vercel affects static paths that regenerate as
partial: RSC requests return `500 INTERNAL_INVALID_RESPONSE`. That
failure predates this change and needs its own follow-up.

Co-authored-by: Arnaud Coral <arnaud@datarooster.io>
unstubbable added a commit that referenced this pull request Nov 11, 2026
With Cache Components enabled, `next start` served a generic fallback
shell when a navigation to a prerendered path missed a custom cache. The
incomplete RSC response crashed the client with React #412, as reported
in #99861. Static paths that regenerated as partial were also served as
static RSC, without the request data required by navigations and runtime
prefetches.

PR #101125 addresses the navigation miss and returns a 404 when a
navigation still receives incomplete output. The router then reloads the
document. This alternative builds on that work and its regression cases
to keep navigation on the client and handle runtime prefetches at their
requested stage.

The handler derives the RSC miss policy from the classification introduced
in #101511 and extended in #101309. RSC outputs classified as `static` or
`blocking` wait for a coalesced prerender when their entry is missing or
hard-expired. Other eligible RSC requests render independently while a
coalesced background prerender populates the cache.

A concrete build-time entry takes precedence over its generic matcher.
A built partial path therefore renders dynamically even when the matcher
requires blocking for paths not generated at build time. Paths generated
on demand retain their matcher's policy after expiration or eviction.

After a blocking prerender, the handler checks the actual result before
serving it. It also checks cached entries and fallback results shared
with document requests. Partial results, missing usable RSC, and generic
fallback shells require a separate RSC render for each caller. That render
runs outside the shared response cache, preserves router state and
prefetch stage, and can reuse the Resume Data Cache from postponed state.
The response isolation from #100960 and #101295 keeps prerender headers
and status separate from the request-specific response.

Deployment parity assumes the proposed compute-based mapping in
nextjs/adapter-vercel#114. That draft still derives `onMiss` from `response`,
and shared metadata consumers also need to recognize `compute: 'dynamic'`.
HTML miss handling in `next start` remains a follow-up.

Co-authored-by: Arnaud Coral <arnaud@datarooster.io>
unstubbable pushed a commit that referenced this pull request Nov 12, 2026
PR #100960 already fixes the header accumulation reported in #94945.
This change retains the Proxy rewrite and `next/font/local` regression
coverage from PR #95558 without its superseded framework change.

The test runs in production and deploy modes. It checks four alternate
links and one font preload across three regenerations and uses a rendered
timestamp to confirm that each regeneration completed.
unstubbable added a commit that referenced this pull request Nov 12, 2026
Adopts #95558. Closes #95558.

Fixes #94945.

Proxy can add alternate-language `Link` headers while `next/font/local`
adds font preloads. PR #100960 already fixes the accumulation reported
in #94945, so this PR retains the regression coverage without the
superseded framework change.

The test checks four alternate links and one font preload across three
confirmed cache regenerations. It runs in production and deploy modes,
reads headers through the standard API, and observes a rendered
timestamp instead of relying on fixed delays.

Co-authored-by: KashapovK <kashapov.konstantin.hr@gmail.com>
unstubbable added a commit that referenced this pull request Nov 13, 2026
With Cache Components enabled, `next start` serves a generic fallback
shell to an RSC navigation when a path prerendered at build time is
missing from the cache. The incomplete RSC response causes the React
#412 crash reported in #99861. The original reproduction uses a custom
cache handler whose store lacks the build-time entry, but the runtime
fix applies regardless of the cache backend.

A related problem occurs when a build-time static path regenerates as
partial: RSC requests still follow the static serving path and lack the
request-specific data needed by navigations and runtime prefetches.

PR #101125 addresses the navigation miss and returns a 404 when a
navigation still receives incomplete output. The router then reloads the
document. This alternative builds on that work and its regression cases
to keep navigation on the client and handle runtime prefetches at their
requested stage.

The handler uses the classification from #101511 and #101309 to choose
how to handle missing or hard-expired prerenders. RSC outputs classified
as `static` or `blocking` wait for a coalesced prerender. Other eligible
requests render independently while a coalesced background prerender
populates the cache. A stale entry that remains usable keeps the
existing stale-while-revalidate behavior.

A concrete build-time entry takes precedence over its generic matcher. A
built partial path therefore renders dynamically even when the matcher
requires blocking for paths not generated at build time. Paths generated
on demand retain their matcher's policy after expiration or eviction.

After a blocking prerender, the handler checks the actual result before
serving it. It also checks cached entries and fallback results shared
with document requests. Partial results, missing usable RSC, and generic
fallback shells require a separate RSC render for each caller. That
render runs outside the shared response cache, preserves router state
and prefetch stage, and can reuse the Resume Data Cache. The response
isolation from #100960 and #101295 keeps prerender headers and status
separate from the request-specific response.

These miss-scheduling changes apply to `next start`. On Vercel, Proxy
manages cache-miss scheduling. The per-output policy in
nextjs/adapter-vercel#162 provides the corresponding deployment
configuration. HTML miss handling in `next start` remains a follow-up.

Fixes #99861
Closes #101125

Co-authored-by: Arnaud Coral <arnaud@datarooster.io>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cacheComponents: Link header from next.config headers() is duplicated and grows on every ISR regeneration

2 participants