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:
- 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.
- 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.
- 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
- 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:211 → handleRevalidate, :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.
Link to the code that reproduces this issue
https://github.com/vicanera/next-isr-regeneration-drops-link-hints
To Reproduce
Next 16.3.5 (identical on 16.3.4), Node 26.2,
cacheComponents: true,partialPrefetching: true, onenext/fontfont, 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:next build,.next/server/app/index.metaholds React's early-hint entry,</_next/static/media/….woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2", and serving the route returns thatLinkheader.RSC: 1, what a client-side navigation sends;Next-Router-Prefetch: 2and3behave the same) leaves an entry with noLinkat all, and no later regeneration brings it back:Linkbefore 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, then2), 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
Linkthe already-finished response had, not the hints React emitted for the new render.Mechanism, traced with the bundled runtime instrumented:
server/response-cache/index.js:211→handleRevalidate,:275). The regeneration renders with the original request'sres; by the time the prerender completes, the stale response has been fully sent (res._res.headersSent === true,writableEnded === true).safelyEmitEarlyPreloadsthe final client prerender (the one created withonHeaders) hasfontPreloadspopulated and callsonHeaders({ Link: ... }).createOnHeadersCallback(server/app-render/stream-ops.node.js:808) forwards toappendHeaderinprerenderToStream(server/app-render/app-render.js:4971), which callsres.appendHeader(name, value)and thensetMetadataHeader(name)(:4962), i.e.metadata.headers[name] = res.getHeader(name).NodeNextResponse.appendHeader(server/base-http/node.js:124) doesthis._res.setHeader(name, [...current, value])on the finishedServerResponse. 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.metadata.headers.linkand therefore the regenerated entry receive. For a Flight response there was noLinkon the finished response, so the entry stores none.At build time the export renders into a
MockedResponsethat 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 pastexpire), 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 (theMockedResponsethe export path already uses), orsetMetadataHeadershould record the value passed toappendHeaderrather than reading it back fromres.Provide environment information
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
Linkheader 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.