vercel / vercel/next.js

Runtime prefetch runs for every dynamic render, and a prefetch export cannot opt a route out

Open
#97,386 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Runtime
Dominant language
JavaScript
Stars
142k
Forks
32.5k
Avg merge
2d 14h
Merged PRs (30d)
351

Description

Link to the code that reproduces this issue

https://github.com/damianfrizzi/next-runtime-prefetch-repro

To Reproduce
git clone https://github.com/damianfrizzi/next-runtime-prefetch-repro
cd next-runtime-prefetch-repro
bun install
./run-all.sh

The route app/item/[id]/page.tsx is a cached shell. It uses 'use cache' and cacheLife('max').
The page gives the shell one request-time child as a prop. The child reads searchParams, so the
route builds as ◐ Partial Prerender. The shell renders a Client Component with 200 rows, and each
row holds the token REPRO_MARKER. The count of that token gives the number of copies of the shell.

measure.sh sends a plain document request. The request carries no RSC header and no
Next-Router-Prefetch header. No <Link> is part of the flow.

Current vs. Expected behavior

run-all.sh prints:

# Configuration Bytes Copies in Flight
1 partialPrefetching: true and prefetch = 'force-disabled' 54,251 2
2 partialPrefetching: true, no prefetch export 54,237 2
3 partialPrefetching: false 30,466 1

Row 1 and row 2 differ by 14 bytes. Those bytes are the export line itself.

1. prefetch = 'force-disabled' does not override the application default

The partialPrefetching reference says:

A segment that exports an explicit prefetch value overrides the app-level default for that
route.

I expect row 1 to hold one copy. It holds two. The gate reads the global flag first and stops:

// server/app-render/app-render.js:475 (Flight) and :2003 (HTML)
if (Boolean(renderOpts.partialPrefetching) || await anySegmentHasPartialPrefetchingEnabled(tree)) {

anySegmentHasPartialPrefetchingEnabled returns true for 'partial' and 'unstable_eager'. It
cannot express an opt-out. spawnRuntimePrefetchWithFilledCaches then prerenders the whole route,
and it does not read the segment configuration.

A route can therefore opt in per segment. A route cannot opt out. The application flag is the only
control, and the adoption guide describes that flag as the end state:

Once every route in scope has prefetch = 'partial', enable the global flag and remove the
per-route exports.

2. The runtime prefetch runs for each dynamic render, not per link

The runtime prefetching guide says:

Runtime prefetching is opted into per link with <Link prefetch={true}>.

and:

Generating it costs a server invocation per prefetchable link, so it is opt-in per link.

I expect a plain document request to carry one copy of the shell. It carries two. The source states
the purpose of the second copy: "so the client can cache runtime-prefetchable content during
hydration". A client that does not hydrate pays the same cost.

Provide environment information
Operating System:
  Platform: darwin
  Arch: arm64
  Version: Darwin Kernel Version 25.5.0
  Available memory (MB): 49152
  Available CPU cores: 14
Binaries:
  Node: 26.5.0
  npm: 11.17.0
Relevant Packages:
  next: 16.3.1 // Latest available version is detected (16.3.1).
  react: 19.2.8
  react-dom: 19.2.8
  typescript: 7.0.2
Next.js Config:
  output: N/A
Which area(s) are affected? (Select all that apply)

Partial Prerendering (PPR), cacheComponents, Runtime

Which stage(s) are affected? (Select all that apply)

next build (local), Vercel (Deployed), next start (local)

Additional context

A production application shows the cost. The route has the same shape: a cached shell and one
streamed child.

partialPrefetching: true partialPrefetching: false
document 452,894 B 299,762 B
wire, brotli 45.7 KB 38.7 KB

The two copies are equal, so brotli compresses them well and the wire cost stays small. The client
parses both copies. On that route, 78 % of the requests carry a declared crawler user agent, and a
crawler never hydrates.

A route that reads searchParams therefore pays for a prefetch that most of its traffic discards,
and it has no way to decline. A fix for behaviour 1 also solves behaviour 2 for the routes that need
it: let prefetch = 'force-disabled' and prefetch = 'auto' gate the spawn, so a route can opt out
while the application flag stays on.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Reproduce the behavior with the linked repository using bun install and ./run-all.sh, then inspect server/app-render/app-render.js at the Flight and HTML gates and trace anySegmentHasPartialPrefetchingEnabled and spawnRuntimePrefetchWithFilledCaches. Verify that force-disabled can prevent the extra runtime prefetch copy while the application flag remains enabled, and confirm the plain document request no longer duplicates the cached shell.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, nextjs, react
Domain
backend, performance, web-dev
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
57/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.