Skip to content

setNextjsCacheKey() omits next-router-segment-prefetch, causing an unbounded RSC prefetch loop on Next 16 prerendered routes #6969

Description

@bevdam

Summary

The CloudFront cache key built by sst.aws.Nextjs does not include the
next-router-segment-prefetch header. A request for a route's segment tree and a request for
its full page therefore hash to the same cache key, so CloudFront answers segment-tree requests
with the cached full-page RSC payload.

On Next 16 this is not merely "a bigger payload". For a build-time prerendered route the
full-page payload carries PrefetchHint.InliningHintsStale, which instructs the client router
to expire the cache entry immediately and re-fetch the corrected tree. That correction can never
arrive, because the corrective request maps to the same cache key and returns the same flagged
payload. The client re-prefetches indefinitely.

Measured on a deployed app: 63–180 requests/second, per open tab, indefinitely, all for the
same URL and the same _rsc token, from every page that links to the prerendered route.

Environment

sst 4.17.1
next 16.3.0
Component sst.aws.Nextjs
open-next.config.ts enableCacheInterception not set (defaults off)

The defect

.sst/platform/src/components/aws/router.ts:2842:

var headers = ["rsc","next-router-prefetch","next-router-state-tree","next-url","x-prerender-revalidate"];
for (var i=0; i<headers.length; i++) cacheKey += getHeader(headers[i]);

Next's CDN caching guide documents that App
Router responses vary on rsc, next-router-state-tree, next-router-prefetch,
next-router-segment-prefetch and next-url. The array above is that list minus
next-router-segment-prefetch.

The deployed cache policy whitelists only the resulting hash, so nothing else re-introduces the
distinction:

Name:  <stack>SiteServerCachePolicy-*      Comment: "SST server response cache policy"
HeadersConfig:       whitelist -> ["x-open-next-cache-key", "x-forwarded-host"]
CookiesConfig:       none
QueryStringsConfig:  all
MinTTL 0 / DefaultTTL 0 / MaxTTL 31536000

Evidence

The build emits two artefacts for the prerendered route /:

artefact size prefetchHints
.next/server/app/index.rsc 10,075 B contains 4608 (4096 + 512 = InliningHintsStale)
.next/server/app/index.segments/_tree.segment.rsc 417 B 4176 / 4256 — no 512 bit

Two requests to the deployed site differing only in the segment-prefetch header returned
byte-identical responses:

curl -s "https://<host>/?_rsc=<token>" \
  -H 'RSC: 1' -H 'Next-Router-Prefetch: 1' -H 'Next-Url: /users'
# 6532 bytes, full-page payload beginning 1:"$Sreact.fragment" / ClientPageRoot

curl -s "https://<host>/?_rsc=<token>" \
  -H 'RSC: 1' -H 'Next-Router-Prefetch: 1' -H 'Next-Url: /users' \
  -H 'Next-Router-Segment-Prefetch: /_tree'
# 6532 bytes, identical — the 417 B tree artefact is never served

Response headers on both:

x-nextjs-prerender: 1,1
x-nextjs-stale-time: 300
cache-control: s-maxage=31536000
x-cache: Hit from cloudfront
age: 3348

Control: a force-dynamic route in the same app returns cache-control: private, no-cache, no-store, is an x-cache: Miss from cloudfront, and correctly returns its 323 B tree payload
(0:{"f":[...]}). So the origin honours the header; only cached responses collapse the
distinction.

Client side, next/dist/client/components/segment-cache/cache.js (~line 939):

if (tree.prefetchHints & PrefetchHint.InliningHintsStale) {
  fulfilledEntry.staleAt = -1;      // always stale -> re-fetch immediately
} else {
  fulfilledEntry.staleAt = now + STATIC_STALETIME_MS;
}

InliningHintsStale is set server-side in
next/dist/server/app-render/create-flight-router-state-from-loader-tree.js for a build-time
prerender generated before collectPrefetchHints runs. The sibling branch in that same function
carries the warning that makes the severity plain:

Do NOT set InliningHintsStale — that would cause the client to enter an infinite re-fetch loop
trying to get hints that will never exist.

Why this was presumably omitted

Next's CDN guide lists next-router-segment-prefetch under "What you can safely ignore":

when omitted on prefetch requests, the server falls back to a broader prefetch payload instead
of a segment-specific one

That is accurate for dynamic routes and benign there. It does not hold for build-time prerendered
routes, where the "broader payload" is the flagged one and the segment request is the documented
recovery path. So this looks like SST correctly following upstream guidance that has a gap, rather
than an oversight — but the consequence on Next 16 is a production traffic loop.

Reproduction

  1. Deploy any Next 16 app via sst.aws.Nextjs containing a route that is not force-dynamic
    (so it is build-time prerendered) and is linked with <Link> from a component rendered on
    every page — a sidebar logo or breadcrumb home icon is the natural case.
  2. Load any other page and watch the network panel.
  3. Requests for the prerendered route's URL repeat indefinitely at roughly one per animation
    frame. No console errors. It pauses while the tab is not painting, which makes it easy to miss.

Suggested fix

Add the header to the array in router.ts:

-var headers = ["rsc","next-router-prefetch","next-router-state-tree","next-url","x-prerender-revalidate"];
+var headers = ["rsc","next-router-prefetch","next-router-segment-prefetch","next-router-state-tree","next-url","x-prerender-revalidate"];

This trades a small amount of cache fragmentation (segment-tree responses cached separately from
full-page responses — which is the correct behaviour) for eliminating the loop.

Workaround for anyone hitting this

Ensure nothing links to a build-time prerendered route: either point shell links at a
force-dynamic route, or set prefetch={false} on those links. Making the route dynamic also
works, but note that route segment config is silently ignored in a 'use client' page — it needs
a server component.

Related

  • opennextjs-aws#1212 — same symptom
    (segment prefetches never served, unbounded loop) via a different cause, a truthiness check on
    prefetchInlining which Next 16 changed from boolean to object. Not applicable here:
    enableCacheInterception is off.
  • vercel/next.js#85489 — bounded 2–3×
    duplicate prefetches on Next 16. Distinct from this unbounded case.
  • anomalyco/sst#6404setNextjsCacheKey() not
    reached for image-optimizer routes. Same function, different defect.

Known gap in this report

We could not directly observe the origin returning the tree artefact for a segment request,
because every unauthenticated request to our deployment is redirected by Clerk middleware before
reaching Next. The inference that the origin would return the tree rests on (a) the artefact
existing in the build output and (b) the force-dynamic control route returning its tree
correctly on a cache miss.


Investigation and initial draft were AI-assisted. All measurements were taken against a live
deployment; the CloudFront cache policy and viewer-request function were read via the AWS CLI,
and the client/server behaviour was verified against the installed next@16.3.0 and sst@4.17.1
sources.

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