Thanks to visit codestin.com
Credit goes to github.com

Skip to content

ISR background revalidation stores the finished response's Link header, dropping React's early hints from regenerated PPR entries #98600

Description

@vicanera

Link to the code that reproduces this issue

https://github.com/vicanera/next-isr-regeneration-drops-link-hints

To Reproduce

git clone https://github.com/vicanera/next-isr-regeneration-drops-link-hints
cd next-isr-regeneration-drops-link-hints
npm ci && npm run build && npm run repro

Next 16.3.5 (identical on 16.3.4), Node 26.2, cacheComponents: true, partialPrefetching: true, one next/font font, a partially prerendered route (static shell + a cookie-reading Suspense hole) whose only data is 'use cache' with a 15 s revalidate window and a short wait in the cached function (a real page's data round trip).

The runner (repro.mjs) does, and prints, the following:

  1. After next build, .next/server/app/index.meta holds React's early-hint entry, </_next/static/media/….woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2", and serving the route returns that Link header.
  2. It waits past the revalidate window and sends one request of each shape, which serves the stale entry and regenerates it in the background; the stored HTML's timestamp shows each regeneration happened.
  3. A document request keeps the hint. A Flight request (RSC: 1, what a client-side navigation sends; Next-Router-Prefetch: 2 and 3 behave the same) leaves an entry with no Link at all, and no later regeneration brings it back:
entry after build:        font=1  </_next/static/media/…woff2>; rel=preload; as="font"…
--- trigger document      wire: font=1   stored after: font=1   regenerated: yes
--- trigger rsc           wire: font=0   stored after: font=0   regenerated: yes
--- trigger prefetch2     wire: font=0   stored after: font=0   regenerated: yes
--- trigger prefetch3     wire: font=0   stored after: font=0   regenerated: yes
  1. With a proxy that sets a Link before the render (PROXY_LINK=1 node repro.mjs --triggers document,document,rsc) the other face of the same read-back shows: each document-triggered regeneration stores the proxy's header and adds one more copy (style=1, then 2), and the next Flight-triggered one replaces the stored value with exactly what that response carried — the proxy's header alone, the font hint gone.

A fully static route does not show the drop: its Flight response is served whole from the entry, headers included, so the read-back happens to return the hint again. The resumed Flight response of a partial route carries no Link.

Current vs. Expected behavior

Current: a runtime (stale-while-revalidate) regeneration of a PPR route stores in the ISR entry whatever Link the already-finished response had, not the hints React emitted for the new render.

Mechanism, traced with the bundled runtime instrumented:

  • The response cache resolves the stale entry first and then runs the same response generator to revalidate in the background (server/response-cache/index.js:211handleRevalidate, :275). The regeneration renders with the original request's res; by the time the prerender completes, the stale response has been fully sent (res._res.headersSent === true, writableEnded === true).
  • React does emit the hints for the new render: in safelyEmitEarlyPreloads the final client prerender (the one created with onHeaders) has fontPreloads populated and calls onHeaders({ Link: ... }).
  • createOnHeadersCallback (server/app-render/stream-ops.node.js:808) forwards to appendHeader in prerenderToStream (server/app-render/app-render.js:4971), which calls res.appendHeader(name, value) and then setMetadataHeader(name) (:4962), i.e. metadata.headers[name] = res.getHeader(name).
  • NodeNextResponse.appendHeader (server/base-http/node.js:124) does this._res.setHeader(name, [...current, value]) on the finished ServerResponse. On Node 26 that neither throws nor stores: res._res.setHeader('x-probe', '1'); res._res.getHeader('x-probe')undefined. res.getHeader('link') then returns the header the finished response was sent with.
  • That value is what metadata.headers.link and therefore the regenerated entry receive. For a Flight response there was no Link on the finished response, so the entry stores none.

At build time the export renders into a MockedResponse that is never finished, so the append and the read-back agree and the entry keeps React's hints. The same holds for a blocking revalidation (entry past expire), where the render completes before anything is sent.

Expected: a regenerated entry carries the same headers a fresh build would produce for that render: React's early-hint Link (and any other header the render emits) independent of the state of the response the stale copy was served on. Either the revalidation render should target a detached response (the MockedResponse the export path already uses), or setMetadataHeader should record the value passed to appendHeader rather than reading it back from res.

Provide environment information

Operating System: Linux (Docker, node:26 image) and macOS 26 (darwin arm64)
Binaries: Node 26.2.0, npm 11
Relevant Packages: next 16.3.5 (and 16.3.4), react 19.2.0, react-dom 19.2.0
Next.js Config: cacheComponents: true, partialPrefetching: true, default filesystem cache handler (the production app uses a custom one; it only observes what it is given)

Which area(s) are affected?

Partial Prerendering (PPR), Runtime (ISR revalidation), Headers (early hints)

Which stage(s) are affected?

next start (local), self-hosted Docker

Additional context

The visible symptom is that after each deploy the routes reached by client navigation lose their font preload Link header one regeneration at a time, while routes that crawlers fetch as documents keep it, so the difference looks random per route. The workaround we shipped is to emit the font hints from middleware on every response and strip React's copy from stored entries, which makes the header deterministic but duplicates work the framework already does.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions